Notifications: bell in the sidebar, email, morning summary, client approval emails

- In-app list with unread count; per-person email and 8:00 summary settings
- Team is told when a client approves or asks for changes, a post fails or only partly publishes,
  or a TikTok post is waiting in the TikTok app; new drafts appear in the bell and the summary
- Clients get one email (batched over 5 minutes) when posts are sent to them, in the agency's name
- SMTP settings in .env; nodemailer pinned; express advisories fixed (npm audit: 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-23 10:07:20 -04:00
parent 92ee5c28b2
commit 3f800d46ad
11 changed files with 437 additions and 64 deletions

View File

@ -56,3 +56,11 @@ ANTHROPIC_API_KEY=
DASHBOARD_AI_MODEL=claude-opus-5-5 DASHBOARD_AI_MODEL=claude-opus-5-5
# Refresh every client's numbers at 03:00 server time # Refresh every client's numbers at 03:00 server time
DASHBOARD_NIGHTLY_REFRESH=true DASHBOARD_NIGHTLY_REFRESH=true
# ---------- Email (notifications, client approval emails, monthly reports) ----------
# Use the support@ mailbox on the Hestia server (Hestia → Mail → quorlasocial.com → support → password).
# SMTP_HOST=mail.quorlasocial.com
# SMTP_PORT=587
# SMTP_USER=support@quorlasocial.com
# SMTP_PASS=
# SMTP_FROM=support@quorlasocial.com

1
.gitignore vendored
View File

@ -29,3 +29,4 @@ graphify-out/
# Local demo recordings # Local demo recordings
*.mp4 *.mp4
!docs/demo-assets/*.mp4 !docs/demo-assets/*.mp4
__pycache__/

View File

@ -4,6 +4,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { EventEmitter } from "node:events";
import { PROJECT_ROOT } from "./env.js"; import { PROJECT_ROOT } from "./env.js";
import * as meta from "./meta.js"; import * as meta from "./meta.js";
import * as extra from "./platforms/index.js"; import * as extra from "./platforms/index.js";
@ -21,6 +22,9 @@ fs.mkdirSync(DIR, { recursive: true });
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return []; } }; const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return []; } };
const write = (items) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(items, null, 2)); fs.renameSync(tmp, FILE); }; const write = (items) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(items, null, 2)); fs.renameSync(tmp, FILE); };
/** Things that happened to posts, for notifications (the dashboard listens; the MCP process doesn't). */
export const events = new EventEmitter();
const emit = (type, post, extra = {}) => { try { events.emit("event", { type, post, ...extra }); } catch { /* listeners must not break publishing */ } };
const log = (entry) => fs.appendFileSync(LOG, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n"); const log = (entry) => fs.appendFileSync(LOG, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
@ -135,6 +139,7 @@ export function createPost({ client, platforms = ["instagram"], type = "IMAGE",
const items = read(); const items = read();
post.warnings = quotaWarnings(post, items); post.warnings = quotaWarnings(post, items);
items.push(post); write(items); items.push(post); write(items);
emit("draft_created", post);
return post; return post;
} }
@ -158,7 +163,7 @@ export function requestClientReview(id, by = "team") {
if (!p) throw err("Post not found.", 404); if (!p) throw err("Post not found.", 404);
if (p.status !== "draft") throw err("Only drafts can be sent to the client."); if (p.status !== "draft") throw err("Only drafts can be sent to the client.");
p.clientReview = { status: "pending", requestedBy: by, requestedAt: new Date().toISOString(), note: "", by: null, decidedAt: null }; p.clientReview = { status: "pending", requestedBy: by, requestedAt: new Date().toISOString(), note: "", by: null, decidedAt: null };
write(items); return p; write(items); emit("client_review_requested", p); return p;
} }
/** The client's answer from their approval link: approved, or changes with a note. */ /** The client's answer from their approval link: approved, or changes with a note. */
@ -170,7 +175,7 @@ export function clientDecision(id, client, { decision, note = "", name = "" }) {
if (p.status !== "draft") throw err("This post has already moved on; ask your agency if something needs changing."); if (p.status !== "draft") throw err("This post has already moved on; ask your agency if something needs changing.");
p.clientReview = { ...p.clientReview, status: decision, note: String(note).slice(0, 2000), by: String(name).slice(0, 80) || "Client", decidedAt: new Date().toISOString() }; p.clientReview = { ...p.clientReview, status: decision, note: String(note).slice(0, 2000), by: String(name).slice(0, 80) || "Client", decidedAt: new Date().toISOString() };
log({ action: `client_${decision}`, post: id, client, by: p.clientReview.by }); log({ action: `client_${decision}`, post: id, client, by: p.clientReview.by });
write(items); return p; write(items); emit("client_decision", p); return p;
} }
/** A person approves the post. Facebook parts more than 10 min away are handed to Facebook's own scheduler. */ /** A person approves the post. Facebook parts more than 10 min away are handed to Facebook's own scheduler. */
@ -277,6 +282,7 @@ export async function runDue({ now = Date.now() } = {}) {
if (p.retryAt && new Date(p.retryAt) > now) continue; // waiting for a platform quota to reset if (p.retryAt && new Date(p.retryAt) > now) continue; // waiting for a platform quota to reset
p.status = "publishing"; write(items); p.status = "publishing"; write(items);
await publishNow(p); write(items); await publishNow(p); write(items);
emit(p.status === "published" ? "published" : p.status === "partial" ? "partly_published" : p.status === "failed" ? "failed" : "waiting", p);
done.push({ id: p.id, client: p.client, status: p.status, errors: p.errors }); done.push({ id: p.id, client: p.client, status: p.status, errors: p.errors });
} }
} finally { fs.rmSync(LOCK, { force: true }); } } finally { fs.rmSync(LOCK, { force: true }); }

View File

@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.296", "@anthropic-ai/claude-agent-sdk": "^0.3.296",
"express": "^4.21.1", "express": "^4.21.1",
"marked": "^12.0.2" "marked": "^12.0.2",
"nodemailer": "10.0.10"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.14", "@tailwindcss/vite": "^4.0.14",
@ -1329,23 +1330,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/send": { "node_modules/@modelcontextprotocol/sdk/node_modules/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@ -2270,40 +2254,69 @@
} }
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.3", "version": "1.20.9",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "3.1.2", "bytes": "~3.1.2",
"content-type": "~1.0.5", "content-type": "~1.0.5",
"debug": "2.6.9", "debug": "2.6.9",
"depd": "2.0.0", "depd": "2.0.0",
"destroy": "1.2.0", "destroy": "~1.2.0",
"http-errors": "2.0.0", "http-errors": "~2.0.1",
"iconv-lite": "0.4.24", "iconv-lite": "~0.4.24",
"on-finished": "2.4.1", "on-finished": "~2.4.1",
"qs": "6.13.0", "qs": "~6.16.0",
"raw-body": "2.5.2", "raw-body": "~2.5.3",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"unpipe": "1.0.0" "unpipe": "~1.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.8", "node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/body-parser/node_modules/raw-body": { "node_modules/body-parser/node_modules/http-errors": {
"version": "2.5.2", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "3.1.2", "depd": "~2.0.0",
"http-errors": "2.0.0", "inherits": "~2.0.4",
"iconv-lite": "0.4.24", "setprototypeof": "~1.2.0",
"unpipe": "1.0.0" "statuses": "~2.0.2",
"toidentifier": "~1.0.1"
}, },
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/body-parser/node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@ -2857,45 +2870,49 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.21.1", "version": "4.22.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
"integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
"body-parser": "1.20.3", "body-parser": "~1.20.5",
"content-disposition": "0.5.4", "content-disposition": "~0.5.4",
"content-type": "~1.0.4", "content-type": "~1.0.4",
"cookie": "0.7.1", "cookie": "~0.7.1",
"cookie-signature": "1.0.6", "cookie-signature": "~1.0.6",
"debug": "2.6.9", "debug": "2.6.9",
"depd": "2.0.0", "depd": "2.0.0",
"encodeurl": "~2.0.0", "encodeurl": "~2.0.0",
"escape-html": "~1.0.3", "escape-html": "~1.0.3",
"etag": "~1.8.1", "etag": "~1.8.1",
"finalhandler": "1.3.1", "finalhandler": "~1.3.1",
"fresh": "0.5.2", "fresh": "~0.5.2",
"http-errors": "2.0.0", "http-errors": "~2.0.0",
"merge-descriptors": "1.0.3", "merge-descriptors": "1.0.3",
"methods": "~1.1.2", "methods": "~1.1.2",
"on-finished": "2.4.1", "on-finished": "~2.4.1",
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "0.1.10", "path-to-regexp": "~0.1.13",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "6.13.0", "qs": "~6.16.0",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
"send": "0.19.0", "send": "~0.19.0",
"serve-static": "1.16.2", "serve-static": "~1.16.2",
"setprototypeof": "1.2.0", "setprototypeof": "1.2.0",
"statuses": "2.0.1", "statuses": "~2.0.1",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"utils-merge": "1.0.1", "utils-merge": "1.0.1",
"vary": "~1.1.2" "vary": "~1.1.2"
}, },
"engines": { "engines": {
"node": ">= 0.10.0" "node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
@ -3706,6 +3723,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nodemailer": {
"version": "10.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz",
"integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -3769,9 +3795,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.10", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/picocolors": { "node_modules/picocolors": {
@ -3857,12 +3883,13 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.13.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.0.6" "es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"

View File

@ -19,7 +19,8 @@
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.296", "@anthropic-ai/claude-agent-sdk": "^0.3.296",
"express": "^4.21.1", "express": "^4.21.1",
"marked": "^12.0.2" "marked": "^12.0.2",
"nodemailer": "10.0.10"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.14", "@tailwindcss/vite": "^4.0.14",

View File

@ -10,6 +10,8 @@ import * as clients from "./clients.js";
import * as metrics from "./metrics.js"; import * as metrics from "./metrics.js";
import * as published from "./published.js"; import * as published from "./published.js";
import * as portal from "./portal.js"; import * as portal from "./portal.js";
import * as notify from "./notify.js";
import * as notifyEvents from "./notify-events.js";
import * as odoo from "./odoo.js"; import * as odoo from "./odoo.js";
import * as jobs from "./jobs.js"; import * as jobs from "./jobs.js";
import * as docs from "./docs.js"; import * as docs from "./docs.js";
@ -320,6 +322,13 @@ app.get("/api/schedule/media", h(async (req, res) => {
})); }));
// ---------- notifications ----------
app.get("/api/notifications", h(async (req) => notify.listFor(req.user.id)));
app.post("/api/notifications/read", h(async (req) => { notify.markRead(req.user.id, Array.isArray(req.body?.ids) ? req.body.ids : null); return notify.listFor(req.user.id); }));
app.get("/api/me/notify", h(async (req) => ({ prefs: notify.prefsFor(req.user.id), email: notify.emailConfigured() })));
app.put("/api/me/notify", h(async (req) => ({ prefs: notify.setPrefs(req.user.id, req.body || {}), email: notify.emailConfigured() })));
app.post("/api/notify/test", h(async (req) => notify.sendEmail({ to: req.user.email, subject: "Quorla test email", heading: "Email from Quorla works", lines: ["You'll get notifications like this one. Change what you receive in Settings → Notifications."], button: { label: "Open Quorla", url: notify.appUrl("/settings") }, fromName: "Quorla" })));
// ---------- content calendar: plan items + library items with a date + queued/published posts ---------- // ---------- content calendar: plan items + library items with a date + queued/published posts ----------
app.get("/api/calendar", h(async (req) => plan.calendar({ client: String(req.query.client || ""), from: String(req.query.from || ""), to: String(req.query.to || "") }))); app.get("/api/calendar", h(async (req) => plan.calendar({ client: String(req.query.client || ""), from: String(req.query.from || ""), to: String(req.query.to || "") })));
app.post("/api/calendar/move", h(async (req) => ({ item: plan.moveEntry(String(req.body?.kind || ""), String(req.body?.id || ""), String(req.body?.date || ""), who(req)) }))); app.post("/api/calendar/move", h(async (req) => ({ item: plan.moveEntry(String(req.body?.kind || ""), String(req.body?.id || ""), String(req.body?.date || ""), who(req)) })));
@ -432,6 +441,7 @@ function scheduleNightly() {
}, next - new Date()); }, next - new Date());
} }
if (process.env.DASHBOARD_NIGHTLY_REFRESH !== "false") scheduleNightly(); if (process.env.DASHBOARD_NIGHTLY_REFRESH !== "false") scheduleNightly();
notifyEvents.start();
// Publish approved posts when they're due (each client's own time zone). Every 2 minutes. // Publish approved posts when they're due (each client's own time zone). Every 2 minutes.
if (process.env.SCHEDULER_ENABLED !== "false") { if (process.env.SCHEDULER_ENABLED !== "false") {

View File

@ -0,0 +1,124 @@
// Who hears about what. Wired to the queue's events once at startup, plus a daily 08:00 summary email.
import * as notify from "./notify.js";
import * as portal from "./portal.js";
import * as clients from "./clients.js";
import { AGENCY_NAME } from "./config.js";
import * as sched from "../../connector/src/schedule.js";
import * as plan from "../../connector/src/plan.js";
import * as extra from "../../connector/src/platforms/index.js";
const PLATFORM = { instagram: "Instagram", facebook: "Facebook", tiktok: "TikTok", youtube: "YouTube", gbp: "Google Business Profile", linkedin: "LinkedIn", pinterest: "Pinterest", threads: "Threads", reddit: "Reddit" };
const names = (pls) => pls.map((p) => PLATFORM[p] || p).join(" + ");
const clientName = (slug) => clients.getClient(slug)?.name || slug;
const short = (s, n = 90) => { const t = String(s || "").replace(/\s+/g, " ").trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
const people = (...xs) => [...new Set(xs.filter(Boolean))];
// Several posts sent to the same client within a few minutes → one email to them.
const pendingClientMail = new Map();
function queueClientEmail(slug) {
clearTimeout(pendingClientMail.get(slug));
pendingClientMail.set(slug, setTimeout(() => { pendingClientMail.delete(slug); emailClient(slug).catch((e) => console.error("Client email failed", e)); }, 5 * 60000));
}
async function emailClient(slug) {
const link = portal.linkFor(slug);
if (!link.enabled || !link.contactEmail) return;
const waiting = sched.listPosts({ client: slug }).filter((p) => p.status === "draft" && p.clientReview?.status === "pending");
if (!waiting.length) return;
const n = waiting.length;
await notify.sendEmail({
to: link.contactEmail,
subject: `${n} post${n === 1 ? "" : "s"} waiting for your approval`,
heading: `Hi${link.contactName ? ` ${link.contactName}` : ""}, ${n === 1 ? "a post is" : `${n} posts are`} ready for you to review`,
lines: [
...waiting.slice(0, 5).map((p) => `${p.localTime} · ${names(p.platforms)}: ${short(p.caption, 80) || "(no caption)"}`),
"Approve them or tell us what to change. Nothing is posted until our team gives the final go-ahead.",
],
button: { label: "Review posts", url: notify.appUrl(`/p/${link.token}`) },
fromName: AGENCY_NAME,
footer: `${AGENCY_NAME} · You're getting this because you review posts for ${clientName(slug)}.`,
});
}
async function onEvent({ type, post: p }) {
const c = clientName(p.client);
const link = "/publishing";
switch (type) {
case "draft_created":
return notify.notify({ type, title: `New draft for ${c} needs approval`, body: `${p.localTime} · ${names(p.platforms)} · ${short(p.caption)}`, link, client: p.client, to: "admins", email: false });
case "client_review_requested":
queueClientEmail(p.client);
return;
case "client_decision": {
const r = p.clientReview;
const ok = r.status === "approved";
return notify.notify({
type, level: ok ? "ok" : "warn", link, client: p.client, to: people("admins", ...recipientsOf(p)),
title: ok ? `${c} approved a post` : `${c} asked for changes`,
body: ok ? `${r.by || "The client"} approved the ${p.localTime} post. It still needs your final approval to publish.` : `${r.by || "The client"}: “${short(r.note, 300)}”`,
});
}
case "published":
if (p.results?.tiktok?.inbox) {
return notify.notify({ type: "tiktok_inbox", level: "warn", link, client: p.client, to: people("admins", ...recipientsOf(p)), title: `Finish ${c}'s TikTok post in the TikTok app`, body: "The video is in the account's TikTok inbox. Open TikTok, choose who can see it and tap Post." });
}
return notify.notify({ type, level: "ok", link: "/published", client: p.client, to: people(...recipientsOf(p)), title: `Published for ${c}`, body: `${names(p.platforms)} · ${short(p.caption)}`, email: false });
case "partly_published":
case "failed":
return notify.notify({
type, level: "bad", link, client: p.client, to: people("admins", ...recipientsOf(p)),
title: type === "failed" ? `A post for ${c} failed` : `A post for ${c} only partly published`,
body: Object.entries(p.errors || {}).map(([k, v]) => `${PLATFORM[k] || k}: ${short(v, 200)}`).join(" · ") || "Open Publishing to see why and retry.",
});
default:
}
}
// "admins" is a group; everyone else by name. Flattened by notify.notify's recipients().
function recipientsOf(p) { return [p.createdBy, p.approvedBy].filter((x) => x && x !== "agent"); }
/** The morning summary for one person, or null if there's nothing to say. */
function digestFor(u) {
const posts = sched.listPosts({});
const isAdmin = u.role === "admin";
const lines = [];
if (isAdmin) {
const drafts = posts.filter((p) => p.status === "draft");
const clientOk = drafts.filter((p) => p.clientReview?.status === "approved");
const changes = drafts.filter((p) => p.clientReview?.status === "changes");
if (drafts.length) lines.push(`${drafts.length} draft${drafts.length === 1 ? "" : "s"} waiting for approval${clientOk.length ? `, ${clientOk.length} already approved by the client` : ""}.`);
if (changes.length) lines.push(`${changes.length} post${changes.length === 1 ? "" : "s"} where the client asked for changes.`);
const dayAgo = Date.now() - 86400000;
const failed = posts.filter((p) => ["failed", "partial"].includes(p.status) && new Date(p.publishedAt || p.publishAt).getTime() >= dayAgo);
if (failed.length) lines.push(`${failed.length} post${failed.length === 1 ? "" : "s"} failed in the last day.`);
const reconnect = extra.vault.listConnections({}).filter((x) => x.status !== "connected");
if (reconnect.length) lines.push(`Reconnect needed: ${reconnect.map((x) => `${clientName(x.client)} ${PLATFORM[x.platform] || x.platform}`).join(", ")}.`);
}
const today = new Date().toISOString().slice(0, 10);
const soon = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
const mine = plan.listItems({ from: today, to: soon }).filter((i) => ["idea", "brief", "production"].includes(i.status) && i.assignee && [u.name, u.email].filter(Boolean).some((n) => i.assignee.toLowerCase().includes(String(n).toLowerCase().split(" ")[0])));
if (mine.length) lines.push(`Due in the next two days for you: ${mine.map((i) => `${clientName(i.client)} “${short(i.title, 40)}” (${i.date})`).join("; ")}.`);
return lines.length ? lines : null;
}
async function sendDigests() {
if (!notify.emailConfigured()) return;
for (const u of (await import("./users.js")).listUsers().filter((x) => x.active && x.email)) {
if (!notify.prefsFor(u.id).digest) continue;
const lines = digestFor(u);
if (!lines) continue;
await notify.sendEmail({ to: u.email, subject: "Your Quorla morning summary", heading: `Good morning${u.name ? `, ${u.name.split(" ")[0]}` : ""}`, lines, button: { label: "Open Quorla", url: notify.appUrl("/publishing") }, fromName: "Quorla" });
}
}
let started = false;
export function start() {
if (started) return; started = true;
sched.events.on("event", (e) => { onEvent(e).catch((err) => console.error("Notification failed", err)); });
const scheduleDigest = () => {
const next = new Date(); next.setHours(8, 0, 0, 0);
if (next <= new Date()) next.setDate(next.getDate() + 1);
setTimeout(async () => { try { await sendDigests(); } catch (e) { console.error("Digest failed", e); } scheduleDigest(); }, next - new Date());
};
scheduleDigest();
}
export { digestFor };

106
dashboard/server/notify.js Normal file
View File

@ -0,0 +1,106 @@
// Notifications: an in-app list for the team (bell in the sidebar) plus email when SMTP is set up.
// Team emails go to people who keep email on in Settings; client emails (posts waiting for their approval)
// go to the reviewer saved on the client's approval link, sent in the agency's name.
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import nodemailer from "nodemailer";
import { DATA_DIR, AGENCY_NAME } from "./config.js";
import * as users from "./users.js";
const FILE = path.join(DATA_DIR, "notifications.json");
const PREFS = path.join(DATA_DIR, "notify-prefs.json");
const KEEP = 500;
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return d; } };
const writeJson = (f, v) => { const tmp = f + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(v)); fs.renameSync(tmp, f); };
// ---------- email ----------
let transport = null;
export function emailConfigured() { return Boolean(process.env.SMTP_HOST); }
function mailer() {
if (!emailConfigured()) return null;
transport ||= nodemailer.createTransport({
host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT || 587), secure: Number(process.env.SMTP_PORT) === 465,
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS || "" } : undefined,
});
return transport;
}
const fromAddress = () => process.env.SMTP_FROM || process.env.SMTP_USER || "";
export const appUrl = (p = "") => `${(process.env.PUBLIC_URL || (process.env.APP_DOMAIN ? `https://${process.env.APP_DOMAIN}` : "http://localhost:4310")).replace(/\/+$/, "")}${p}`;
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
function html({ heading, lines, button, footer }) {
return `<!doctype html><html><body style="margin:0;background:#f5f6f8;font-family:Inter,Segoe UI,Arial,sans-serif;color:#1f2024">
<div style="max-width:560px;margin:0 auto;padding:28px 16px"><div style="background:#fff;border:1px solid #e4e7ec;border-radius:12px;padding:24px">
<h1 style="font-size:19px;margin:0 0 12px;color:#242142">${esc(heading)}</h1>
${lines.map((l) => `<p style="font-size:14.5px;line-height:1.55;margin:0 0 10px">${esc(l)}</p>`).join("")}
${button ? `<p style="margin:18px 0 4px"><a href="${esc(button.url)}" style="display:inline-block;background:#242142;color:#fff;text-decoration:none;font-weight:600;padding:11px 18px;border-radius:8px">${esc(button.label)}</a></p>` : ""}
</div><p style="font-size:12px;color:#6b6e78;text-align:center;margin-top:14px">${esc(footer || AGENCY_NAME)}</p></div></body></html>`;
}
/** Send one email. Returns { sent } or { sent:false, reason }. Never throws (notifications mustn't break work). */
export async function sendEmail({ to, subject, heading, lines, button, footer, fromName, replyTo }) {
const m = mailer();
if (!m) return { sent: false, reason: "Email isn't set up (SMTP_HOST in .env)." };
if (!to) return { sent: false, reason: "No recipient." };
try {
await m.sendMail({
from: { name: fromName || AGENCY_NAME, address: fromAddress() }, to, subject, replyTo,
text: [heading, "", ...lines, ...(button ? ["", `${button.label}: ${button.url}`] : [])].join("\n"),
html: html({ heading, lines, button, footer }),
});
return { sent: true };
} catch (e) {
console.error("Email failed:", e.message);
return { sent: false, reason: e.message };
}
}
// ---------- preferences ----------
const DEFAULT_PREFS = { email: true, digest: true };
export function prefsFor(userId) { return { ...DEFAULT_PREFS, ...(readJson(PREFS, {})[userId] || {}) }; }
export function setPrefs(userId, patch) {
const all = readJson(PREFS, {});
all[userId] = { ...prefsFor(userId), ...Object.fromEntries(Object.entries(patch).filter(([k]) => k in DEFAULT_PREFS).map(([k, v]) => [k, Boolean(v)])) };
writeJson(PREFS, all);
return all[userId];
}
// ---------- in-app list ----------
const activeUsers = () => users.listUsers().filter((u) => u.active);
function recipients(to) {
const all = activeUsers();
if (to === "admins") return all.filter((u) => u.role === "admin");
if (to === "all") return all;
const want = new Set((to || []).map((x) => String(x).toLowerCase()));
return all.filter((u) => (want.has("admins") && u.role === "admin") || want.has(u.id) || want.has(String(u.email).toLowerCase()) || want.has(String(u.name || "").toLowerCase()));
}
/**
* Record a notification for some team members and email those who want email.
* to: "admins" | "all" | [user ids / emails / names]. level: info | ok | warn | bad.
*/
export async function notify({ type, title, body = "", link = "", client = null, to = "admins", level = "info", email = true }) {
const people = recipients(to);
if (!people.length) return;
const n = { id: crypto.randomUUID().slice(0, 10), at: new Date().toISOString(), type, title, body, link, client, level, for: people.map((u) => u.id), readBy: [] };
const list = readJson(FILE, []);
list.push(n);
writeJson(FILE, list.slice(-KEEP));
if (!email || !emailConfigured()) return;
for (const u of people) {
if (!prefsFor(u.id).email || !u.email) continue;
await sendEmail({ to: u.email, subject: title, heading: title, lines: body ? [body] : [], button: link ? { label: "Open in Quorla", url: appUrl(link) } : null, fromName: "Quorla" });
}
}
export function listFor(userId, { limit = 50 } = {}) {
const mine = readJson(FILE, []).filter((n) => n.for.includes(userId)).reverse();
return { unread: mine.filter((n) => !n.readBy.includes(userId)).length, items: mine.slice(0, limit).map(({ for: _f, readBy, ...n }) => ({ ...n, read: readBy.includes(userId) })) };
}
export function markRead(userId, ids) {
const list = readJson(FILE, []);
for (const n of list) if (n.for.includes(userId) && (!ids || ids.includes(n.id)) && !n.readBy.includes(userId)) n.readBy.push(userId);
writeJson(FILE, list);
}

View File

@ -13,6 +13,7 @@ import Publishing from "./pages/Publishing.jsx";
import Published from "./pages/Published.jsx"; import Published from "./pages/Published.jsx";
import Calendar from "./pages/Calendar.jsx"; import Calendar from "./pages/Calendar.jsx";
import Competitors from "./pages/Competitors.jsx"; import Competitors from "./pages/Competitors.jsx";
import NotificationBell from "./Notifications.jsx";
import Inbox from "./pages/Inbox.jsx"; import Inbox from "./pages/Inbox.jsx";
import Library from "./pages/Library.jsx"; import Library from "./pages/Library.jsx";
import Connections from "./pages/Connections.jsx"; import Connections from "./pages/Connections.jsx";
@ -40,7 +41,10 @@ function Shell({ children }) {
<div className="min-h-screen flex"> <div className="min-h-screen flex">
<aside className="w-[228px] shrink-0 bg-white border-r border-line flex flex-col fixed inset-y-0 left-0 z-10"> <aside className="w-[228px] shrink-0 bg-white border-r border-line flex flex-col fixed inset-y-0 left-0 z-10">
<div className="px-4 pt-4 pb-3"> <div className="px-4 pt-4 pb-3">
<div className="flex items-center justify-between">
<img src="/brand/logo.png" alt="Quorla" className="h-[30px] w-auto" /> <img src="/brand/logo.png" alt="Quorla" className="h-[30px] w-auto" />
<NotificationBell />
</div>
{agency && <div className="mt-2.5 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint truncate" title={agency}>{agency}</div>} {agency && <div className="mt-2.5 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint truncate" title={agency}>{agency}</div>}
</div> </div>
<nav className="px-2 mt-2 space-y-0.5 flex-1"> <nav className="px-2 mt-2 space-y-0.5 flex-1">

View File

@ -0,0 +1,60 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Bell, CheckCheck } from "lucide-react";
import { api } from "./api.js";
import { cx, timeAgo } from "./ui.jsx";
const DOT = { ok: "bg-ok", warn: "bg-warn", bad: "bg-bad", info: "bg-brand" };
/** Sidebar bell: unread count, a list of recent notifications, mark-as-read. Polls every 60 s. */
export default function NotificationBell() {
const nav = useNavigate();
const [data, setData] = useState({ unread: 0, items: [] });
const [open, setOpen] = useState(false);
const box = useRef(null);
const load = useCallback(() => api("/api/notifications").then(setData).catch(() => {}), []);
useEffect(() => { load(); const t = setInterval(load, 60000); return () => clearInterval(t); }, [load]);
useEffect(() => {
if (!open) return;
const close = (e) => { if (!box.current?.contains(e.target)) setOpen(false); };
const esc = (e) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", close); document.addEventListener("keydown", esc);
return () => { document.removeEventListener("mousedown", close); document.removeEventListener("keydown", esc); };
}, [open]);
const read = (ids) => api("/api/notifications/read", { method: "POST", body: { ids } }).then(setData).catch(() => {});
const go = (n) => { if (!n.read) read([n.id]); setOpen(false); if (n.link) nav(n.link); };
return (
<div className="relative" ref={box}>
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} aria-label={`Notifications${data.unread ? `, ${data.unread} unread` : ""}`}
className="relative p-1.5 rounded-md hover:bg-canvas text-ink-2">
<Bell className="size-[18px]" />
{data.unread > 0 && <span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 rounded-full bg-accent text-[10px] font-bold text-white grid place-items-center num">{data.unread > 99 ? "99+" : data.unread}</span>}
</button>
{open && (
<div className="absolute left-0 top-9 z-30 w-[340px] panel shadow-xl overflow-hidden" role="dialog" aria-label="Notifications">
<div className="flex items-center justify-between px-3.5 py-2.5 border-b border-line">
<span className="text-[13px] font-semibold">Notifications</span>
{data.unread > 0 && <button type="button" onClick={() => read(null)} className="text-[12px] text-brand-deep hover:underline inline-flex items-center gap-1"><CheckCheck className="size-3.5" />Mark all read</button>}
</div>
<ul className="max-h-[420px] overflow-y-auto divide-y divide-line">
{!data.items.length && <li className="px-3.5 py-6 text-center text-[13px] text-muted">Nothing yet. You'll see approvals, client answers and failed posts here.</li>}
{data.items.map((n) => (
<li key={n.id}>
<button type="button" onClick={() => go(n)} className={cx("w-full text-left px-3.5 py-2.5 flex gap-2.5 hover:bg-canvas", !n.read && "bg-brand-soft/40")}>
<span className={cx("mt-1.5 size-2 rounded-full shrink-0", n.read ? "bg-line-strong" : DOT[n.level] || "bg-brand")} aria-hidden="true" />
<span className="min-w-0">
<span className={cx("block text-[13px]", !n.read && "font-semibold")}>{n.title}</span>
{n.body && <span className="block text-[12px] text-muted line-clamp-2">{n.body}</span>}
<span className="block text-[11px] text-faint mt-0.5">{timeAgo(n.at)}</span>
</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@ -212,6 +212,31 @@ function TeamPanel() {
); );
} }
function NotificationsPanel() {
const [d, setD] = useState(null);
const [busy, setBusy] = useState(null);
const [msg, setMsg] = useState(null);
useEffect(() => { api("/api/me/notify").then(setD).catch(setMsg); }, []);
if (!d) return null;
const toggle = (k) => { setBusy(k); api("/api/me/notify", { method: "PUT", body: { [k]: !d.prefs[k] } }).then(setD).catch(setMsg).finally(() => setBusy(null)); };
const test = async () => { setBusy("test"); setMsg(null); try { const r = await api("/api/notify/test", { method: "POST" }); setMsg(r.sent ? "Test email sent. Check your inbox (and spam)." : new Error(r.reason)); } catch (e) { setMsg(e); } setBusy(null); };
return (
<Panel title="Notifications" subtitle="The bell at the top of the sidebar always shows them. Choose what also comes by email." bodyClass="px-5 py-4 space-y-3">
{!d.email && <div className="text-[13px] rounded-md bg-warn-soft text-warn px-3 py-2">Email isn't set up on this server yet, so notifications only appear in the bell. An admin adds SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS and SMTP_FROM to the server's .env.</div>}
<label htmlFor="np-email" className="flex items-start gap-2.5 text-[13.5px]">
<input id="np-email" type="checkbox" className="mt-1" checked={d.prefs.email} disabled={busy === "email"} onChange={() => toggle("email")} />
<span><b>Email me when something needs me</b><br /><span className="text-muted text-[12.5px]">A client approved or asked for changes, a post failed, or a TikTok post is waiting in the TikTok app.</span></span>
</label>
<label htmlFor="np-digest" className="flex items-start gap-2.5 text-[13.5px]">
<input id="np-digest" type="checkbox" className="mt-1" checked={d.prefs.digest} disabled={busy === "digest"} onChange={() => toggle("digest")} />
<span><b>Morning summary at 8:00</b><br /><span className="text-muted text-[12.5px]">Drafts waiting for approval, client answers, failed posts, connections to reconnect, and your planned work due in the next two days. Only sent when there's something to say.</span></span>
</label>
{d.email && <Button size="sm" loading={busy === "test"} onClick={test}>Send me a test email</Button>}
{msg && (typeof msg === "string" ? <div className="text-[12.5px] text-ok">{msg}</div> : <ErrorNote error={msg} />)}
</Panel>
);
}
export default function Settings() { export default function Settings() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [s, setS] = useState(null); const [s, setS] = useState(null);
@ -225,6 +250,7 @@ export default function Settings() {
<p className="text-muted mt-0.5">Your account{isAdmin ? ", the team's logins" : ""} and this dashboard's connections.</p> <p className="text-muted mt-0.5">Your account{isAdmin ? ", the team's logins" : ""} and this dashboard's connections.</p>
</header> </header>
<AccountPanel /> <AccountPanel />
<NotificationsPanel />
{isAdmin && <TeamPanel />} {isAdmin && <TeamPanel />}
<ErrorNote error={error} /> <ErrorNote error={error} />
<Panel title="Connections" subtitle="Changed in the server's .env for now." bodyClass="divide-y divide-line"> <Panel title="Connections" subtitle="Changed in the server's .env for now." bodyClass="divide-y divide-line">