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:
parent
92ee5c28b2
commit
3f800d46ad
@ -56,3 +56,11 @@ ANTHROPIC_API_KEY=
|
||||
DASHBOARD_AI_MODEL=claude-opus-5-5
|
||||
# Refresh every client's numbers at 03:00 server time
|
||||
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
1
.gitignore
vendored
@ -29,3 +29,4 @@ graphify-out/
|
||||
# Local demo recordings
|
||||
*.mp4
|
||||
!docs/demo-assets/*.mp4
|
||||
__pycache__/
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PROJECT_ROOT } from "./env.js";
|
||||
import * as meta from "./meta.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 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 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();
|
||||
post.warnings = quotaWarnings(post, items);
|
||||
items.push(post); write(items);
|
||||
emit("draft_created", post);
|
||||
return post;
|
||||
}
|
||||
|
||||
@ -158,7 +163,7 @@ export function requestClientReview(id, by = "team") {
|
||||
if (!p) throw err("Post not found.", 404);
|
||||
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 };
|
||||
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. */
|
||||
@ -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.");
|
||||
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 });
|
||||
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. */
|
||||
@ -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
|
||||
p.status = "publishing"; 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 });
|
||||
}
|
||||
} finally { fs.rmSync(LOCK, { force: true }); }
|
||||
|
||||
147
dashboard/package-lock.json
generated
147
dashboard/package-lock.json
generated
@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.296",
|
||||
"express": "^4.21.1",
|
||||
"marked": "^12.0.2"
|
||||
"marked": "^12.0.2",
|
||||
"nodemailer": "10.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
@ -1329,23 +1330,6 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
@ -2270,40 +2254,69 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"version": "1.20.9",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
|
||||
"integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.16.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/raw-body": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"node_modules/body-parser/node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.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": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@ -2857,45 +2870,49 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
|
||||
"integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
|
||||
"version": "4.22.3",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
|
||||
"integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.10",
|
||||
"path-to-regexp": "~0.1.13",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"qs": "~6.16.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
@ -3706,6 +3723,15 @@
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -3769,9 +3795,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
|
||||
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==",
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
@ -3857,12 +3883,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
|
||||
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
|
||||
@ -19,7 +19,8 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.296",
|
||||
"express": "^4.21.1",
|
||||
"marked": "^12.0.2"
|
||||
"marked": "^12.0.2",
|
||||
"nodemailer": "10.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
|
||||
@ -10,6 +10,8 @@ import * as clients from "./clients.js";
|
||||
import * as metrics from "./metrics.js";
|
||||
import * as published from "./published.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 jobs from "./jobs.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 ----------
|
||||
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)) })));
|
||||
@ -432,6 +441,7 @@ function scheduleNightly() {
|
||||
}, next - new Date());
|
||||
}
|
||||
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.
|
||||
if (process.env.SCHEDULER_ENABLED !== "false") {
|
||||
|
||||
124
dashboard/server/notify-events.js
Normal file
124
dashboard/server/notify-events.js
Normal 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
106
dashboard/server/notify.js
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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);
|
||||
}
|
||||
@ -13,6 +13,7 @@ import Publishing from "./pages/Publishing.jsx";
|
||||
import Published from "./pages/Published.jsx";
|
||||
import Calendar from "./pages/Calendar.jsx";
|
||||
import Competitors from "./pages/Competitors.jsx";
|
||||
import NotificationBell from "./Notifications.jsx";
|
||||
import Inbox from "./pages/Inbox.jsx";
|
||||
import Library from "./pages/Library.jsx";
|
||||
import Connections from "./pages/Connections.jsx";
|
||||
@ -40,7 +41,10 @@ function Shell({ children }) {
|
||||
<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">
|
||||
<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" />
|
||||
<NotificationBell />
|
||||
</div>
|
||||
{agency && <div className="mt-2.5 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint truncate" title={agency}>{agency}</div>}
|
||||
</div>
|
||||
<nav className="px-2 mt-2 space-y-0.5 flex-1">
|
||||
|
||||
60
dashboard/web/src/Notifications.jsx
Normal file
60
dashboard/web/src/Notifications.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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() {
|
||||
const { isAdmin } = useAuth();
|
||||
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>
|
||||
</header>
|
||||
<AccountPanel />
|
||||
<NotificationsPanel />
|
||||
{isAdmin && <TeamPanel />}
|
||||
<ErrorNote error={error} />
|
||||
<Panel title="Connections" subtitle="Changed in the server's .env for now." bodyClass="divide-y divide-line">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user