// 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 };