quorlasocial/dashboard/server/notify-events.js
metatroncubeswdev 82814f3757 White-label workspace and getting-started checklist
- Settings → Workspace: agency name, logo, brand colour, support email, website
- Logo replaces Quorla's in the app (with 'Powered by Quorla'), sign-in page, client approval page and PDF reports;
  brand colour drives buttons, links and highlights; emails and reports use the agency name
- Getting started checklist on the Clients page for admins, checked against the real state
  (workspace, Meta, first client, team, platforms, email, library, first post, approval link); dismissible

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:28:23 -04:00

125 lines
7.4 KiB
JavaScript

// 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 { agencyName } from "./workspace.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: agencyName(),
footer: `${agencyName()} · 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 };