diff --git a/.env.example b/.env.example index 6ccf450..b033bde 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 4969927..dd84269 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ graphify-out/ # Local demo recordings *.mp4 !docs/demo-assets/*.mp4 +__pycache__/ diff --git a/connector/src/schedule.js b/connector/src/schedule.js index 2782692..c0b551e 100644 --- a/connector/src/schedule.js +++ b/connector/src/schedule.js @@ -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 }); } diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 4ab886e..a2443dc 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -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" diff --git a/dashboard/package.json b/dashboard/package.json index 2c276cb..2350b80 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -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", diff --git a/dashboard/server/index.js b/dashboard/server/index.js index b34054b..1cd06ed 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -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") { diff --git a/dashboard/server/notify-events.js b/dashboard/server/notify-events.js new file mode 100644 index 0000000..20143e5 --- /dev/null +++ b/dashboard/server/notify-events.js @@ -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 }; diff --git a/dashboard/server/notify.js b/dashboard/server/notify.js new file mode 100644 index 0000000..8e9bf44 --- /dev/null +++ b/dashboard/server/notify.js @@ -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 ` +
+

${esc(heading)}

+${lines.map((l) => `

${esc(l)}

`).join("")} +${button ? `

${esc(button.label)}

` : ""} +

${esc(footer || AGENCY_NAME)}

`; +} + +/** 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); +} diff --git a/dashboard/web/src/App.jsx b/dashboard/web/src/App.jsx index 1671f01..491ff13 100644 --- a/dashboard/web/src/App.jsx +++ b/dashboard/web/src/App.jsx @@ -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 }) {