diff --git a/.claude/skills/weekly-plan/SKILL.md b/.claude/skills/weekly-plan/SKILL.md index 35a47ba..adfbebe 100644 --- a/.claude/skills/weekly-plan/SKILL.md +++ b/.claude/skills/weekly-plan/SKILL.md @@ -12,4 +12,9 @@ For each client requested: - At least one post that tests something new; say what it's testing. 5. For every post that needs creative work, write a brief using `playbooks/team-briefs.md`. 6. Save to `clients//calendar/YYYY-Www.md` (ISO week of the week being planned). -7. Give the user a short summary and ask for approval before anything goes to the team or the client. +7. Put the week on the dashboard's Content calendar: first call `content_plan` for that week so nothing is doubled + (approved library items and queued posts already show there), then `plan_add` one item per new post with date, + optional time, a short title, format, platforms, status `brief` (or `idea` if it still needs a concept), the brief + and the draft caption. Items with finished, approved library work should be scheduled with `schedule_from_library` + instead of added to the plan. +8. Give the user a short summary and ask for approval before anything goes to the team or the client. diff --git a/.env.example b/.env.example index 6ccf450..93e8fe7 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,22 @@ 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 + +# ---------- Hosted Quorla control centre (only on app.quorlasocial.com) ---------- +# QUORLA_CONTROL=true +# TENANT_BASE_DOMAIN=quorlasocial.com +# PROVISION_TOKEN= # openssl rand -hex 32; same value in deploy/provision.env +# STRIPE_SECRET_KEY= # sk_test_… while testing +# STRIPE_PRICE_ID= # price_… of the hosted plan (monthly) +# STRIPE_WEBHOOK_SECRET= # whsec_… from the webhook endpoint https://app.quorlasocial.com/control/stripe/webhook +# STRIPE_TRIAL_DAYS=14 +# In a hosted agency workspace (set by the provisioning script): +# SETUP_TOKEN= # only the emailed setup link can create the first admin diff --git a/.gitignore b/.gitignore index 4969927..bd1cdea 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,8 @@ graphify-out/ # Local demo recordings *.mp4 !docs/demo-assets/*.mp4 +__pycache__/ + +# Hosted Quorla provisioning secrets +deploy/provision.env +deploy/tenant-shared.env diff --git a/HANDOFF.md b/HANDOFF.md index 2db61ad..26fe7fc 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -40,6 +40,19 @@ Request-access buttons email support@quorlasocial.com: make sure that mailbox ex 6. Later: marketing site, Stripe. (The dashboard now uses the Quorla logo and colours; PDF reports keep the agency branding.) 7. Optional: on the server, run `git stash drop` (left over from the branding CRLF fix). +## Built on branch feature/internal-pages (23 Oct 2026) +Published page (results per post) · Content calendar (plan → production → ready, drag to reschedule) · Competitors · +Client approval portal (/p/) · Notifications (bell, email, 8:00 summary) · Monthly client report (auto + email) · +Team task flow (assign → notify/Odoo task → upload links to card) · Inbox: Google reviews + YouTube comments · +Best time to post · Brand kit + caption checks · Link-in-bio pages (/l/
) · Bulk scheduling · +Admin: system health + audit log · Getting-started checklist · White-label workspace · Hosted Quorla (sign-up, Stripe, +one container per agency; docs/multi-tenant.md). + +After merging and deploying: +- Add SMTP_* to the server .env (support@ mailbox) so emails go out; check Admin → System health. +- docker-compose now mounts ./backups read-only: run `docker compose up -d` once so health sees the backups. +- Hosted plan stays off until QUORLA_CONTROL=true and the steps in docs/multi-tenant.md are done. + ## Rules that don't change - Nothing publishes without a person approving the exact caption and media. - White-label: no Metatroncube mention on client posts. diff --git a/connector/src/brandkit.js b/connector/src/brandkit.js new file mode 100644 index 0000000..2745ec5 --- /dev/null +++ b/connector/src/brandkit.js @@ -0,0 +1,71 @@ +// Brand kit per client: saved hashtag sets, approved phrases/CTAs, words to avoid. +// checkCaption() flags problems before a post goes for approval (shared by the dashboard, the queue and the MCP tools). +import fs from "node:fs"; +import path from "node:path"; +import { PROJECT_ROOT } from "./env.js"; + +const FILE = process.env.QUORLA_BRANDKIT_FILE || path.join(PROJECT_ROOT, "data", "brandkit.json"); +const CLIENTS = path.join(PROJECT_ROOT, "clients"); +fs.mkdirSync(path.dirname(FILE), { recursive: true }); +const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } }; +const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); + +// Phrases that read as AI-written or generic (project writing rules). +const GENERIC = ["elevate your", "unlock", "in today's fast-paced world", "look no further", "game-changer", "game changer", "take it to the next level", "dive into", "seamless", "revolutionize", "embark on"]; +// White-label: the agency must never appear in a client's public post. +const AGENCY_WORDS = ["metatroncube", "metatron cube", "quorla"]; +const LIMITS = { instagram: 2200, facebook: 63206, tiktok: 2200, youtube: 5000, gbp: 1500, linkedin: 3000, threads: 500, pinterest: 500, reddit: 40000 }; + +function checkClient(slug) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || !fs.existsSync(path.join(CLIENTS, slug))) throw err("Unknown client.", 404); + return slug; +} +const tagList = (v) => [...new Set(String(Array.isArray(v) ? v.join(" ") : v || "").split(/[\s,]+/).map((t) => t.trim()).filter(Boolean).map((t) => `#${t.replace(/^#+/, "")}`).filter((t) => /^#[\p{L}\p{N}_]{1,60}$/u.test(t)))]; +const lines = (v) => (Array.isArray(v) ? v : String(v || "").split(/\n/)).map((x) => String(x).trim()).filter(Boolean).slice(0, 200); + +export function get(client) { + checkClient(client); + const k = read()[client] || {}; + return { hashtagSets: k.hashtagSets || [], phrases: k.phrases || [], avoid: k.avoid || [], notes: k.notes || "", updatedAt: k.updatedAt || null, updatedBy: k.updatedBy || null }; +} + +export function save(client, patch, by = "team") { + checkClient(client); + const db = read(); + const cur = get(client); + const next = { + hashtagSets: patch.hashtagSets !== undefined ? (patch.hashtagSets || []).slice(0, 30).map((s) => ({ name: String(s.name || "").trim().slice(0, 60) || "Set", tags: tagList(s.tags).slice(0, 30) })).filter((s) => s.tags.length) : cur.hashtagSets, + phrases: patch.phrases !== undefined ? lines(patch.phrases).map((x) => x.slice(0, 300)) : cur.phrases, + avoid: patch.avoid !== undefined ? lines(patch.avoid).map((x) => x.slice(0, 80)) : cur.avoid, + notes: patch.notes !== undefined ? String(patch.notes || "").slice(0, 3000) : cur.notes, + updatedAt: new Date().toISOString(), updatedBy: by, + }; + db[client] = next; + const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE); + return next; +} + +const EMOJI = /\p{Extended_Pictographic}/gu; +/** + * Problems in a caption for this client: { level: "bad" | "warn", text }. + * bad = must fix (agency named in a client post, over the platform limit); warn = worth a second look. + */ +export function checkCaption(client, caption, { platforms = [] } = {}) { + const text = String(caption || ""); + const low = text.toLowerCase(); + const out = []; + let kit = { avoid: [] }; + try { kit = get(client); } catch { /* unknown client: generic checks only */ } + // The agency's own account (its client name contains the agency name) may of course mention itself. + let ownName = ""; + try { ownName = JSON.parse(fs.readFileSync(path.join(CLIENTS, client, "client.json"), "utf8")).name || ""; } catch { /* no client.json */ } + const isAgencyItself = AGENCY_WORDS.some((w) => `${ownName} ${client}`.toLowerCase().replace(/[-_]/g, " ").includes(w)); + if (!isAgencyItself) for (const w of AGENCY_WORDS) if (low.includes(w)) out.push({ level: "bad", text: `Mentions “${w}”. Client posts must never name the agency.` }); + for (const p of platforms) if (LIMITS[p] && text.length > LIMITS[p]) out.push({ level: "bad", text: `Too long for ${p}: ${text.length} of ${LIMITS[p]} characters.` }); + for (const w of kit.avoid) if (w && low.includes(w.toLowerCase())) out.push({ level: "warn", text: `Uses “${w}”, which is on this client's avoid list.` }); + for (const g of GENERIC) if (low.includes(g)) out.push({ level: "warn", text: `“${g}” sounds generic. Say it the way the client would.` }); + const tags = text.match(/#[\p{L}\p{N}_]+/gu) || []; + if (tags.length > 10) out.push({ level: "warn", text: `${tags.length} hashtags. 3–8 relevant ones usually do better.` }); + if ((text.match(EMOJI) || []).length > 8) out.push({ level: "warn", text: "Lots of emoji. Keep a few that add meaning." }); + return out; +} diff --git a/connector/src/competitors.js b/connector/src/competitors.js new file mode 100644 index 0000000..f9704c3 --- /dev/null +++ b/connector/src/competitors.js @@ -0,0 +1,158 @@ +// Competitors per client: Instagram Business/Creator accounts the team tracks (added by hand or suggested by the AI). +// Each refresh stores one snapshot per account per day (followers, posts, recent media), so growth can be charted. +// Uses Instagram business_discovery through the client's (or the agency's) connected Instagram account. +import fs from "node:fs"; +import path from "node:path"; +import { PROJECT_ROOT } from "./env.js"; +import * as meta from "./meta.js"; + +const FILE = process.env.QUORLA_COMPETITORS_FILE || path.join(PROJECT_ROOT, "data", "competitors.json"); +const CLIENTS = path.join(PROJECT_ROOT, "clients"); +const KEEP_DAYS = 180; +fs.mkdirSync(path.dirname(FILE), { recursive: true }); + +const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } }; +const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db)); fs.renameSync(tmp, FILE); }; +const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); +const today = () => new Date().toISOString().slice(0, 10); +const handle = (u) => String(u || "").trim().replace(/^@/, "").replace(/^https?:\/\/(www\.)?instagram\.com\//i, "").replace(/[/?#].*$/, "").toLowerCase(); + +function checkClient(slug) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || !fs.existsSync(path.join(CLIENTS, slug))) throw err("Unknown client.", 404); + return slug; +} +function clientLink(slug) { try { return JSON.parse(fs.readFileSync(path.join(CLIENTS, slug, "client.json"), "utf8")); } catch { return {}; } } + +/** An Instagram account id to run business_discovery from: the client's own, else any the agency manages. */ +async function viewerIgId(slug) { + const link = clientLink(slug); + if (link.igId) return link.igId; + const any = (await meta.listAccounts()).find((a) => a.instagram?.id); + if (!any) throw err("No Instagram account is connected to the agency's Meta account, so competitors can't be looked up."); + return any.instagram.id; +} + +async function lookup(igId, username) { + try { + const b = await meta.igBusinessDiscovery(igId, username, { posts: 25 }); + if (!b?.username) throw new Error("not found"); + return b; + } catch (e) { + throw err(`Couldn't read @${username}. Instagram only shares public Business and Creator accounts; check the handle and that the account is public and professional. (${e.message})`); + } +} + +function snapshotOf(b) { + return { + date: today(), followers: b.followers_count ?? null, following: b.follows_count ?? null, mediaCount: b.media_count ?? null, + name: b.name || null, bio: (b.biography || "").slice(0, 300), + media: (b.media || []).map((m) => ({ id: m.id, at: m.timestamp, likes: m.like_count ?? null, comments: m.comments_count ?? null, type: m.media_product_type === "REELS" ? "Reel" : m.media_type === "CAROUSEL_ALBUM" ? "Carousel" : m.media_type === "VIDEO" ? "Video" : "Image", permalink: m.permalink, caption: (m.caption || "").slice(0, 200) })), + }; +} +function addSnapshot(entry, snap) { + entry.snapshots = (entry.snapshots || []).filter((s) => s.date !== snap.date); + // Keep full media only on the latest snapshot; older ones keep the counts for the growth chart. + entry.snapshots = entry.snapshots.map(({ media, ...rest }) => rest); + entry.snapshots.push(snap); + const cutoff = new Date(Date.now() - KEEP_DAYS * 86400000).toISOString().slice(0, 10); + entry.snapshots = entry.snapshots.filter((s) => s.date >= cutoff); +} + +export function listTracked(client) { + checkClient(client); + return (read()[client]?.accounts || []).map(({ snapshots, ...a }) => ({ ...a, lastChecked: snapshots?.at(-1)?.date || null })); +} + +export async function add(client, username, { by = "team", source = "manual", note = "" } = {}) { + checkClient(client); + const u = handle(username); + if (!/^[a-z0-9._]{1,30}$/.test(u)) throw err("Enter an Instagram handle, like @bakery_name."); + const db = read(); + const c = (db[client] ||= { accounts: [] }); + if (c.accounts.some((a) => a.username === u)) throw err(`@${u} is already tracked for this client.`); + if (c.accounts.length >= 10) throw err("Track up to 10 competitors per client. Remove one first."); + const own = clientLink(client); + const b = await lookup(await viewerIgId(client), u); + if (own.igId && String(b.id) === String(own.igId)) throw err("That's the client's own account."); + const entry = { username: u, name: b.name || null, source, note: String(note).slice(0, 300), addedBy: by, addedAt: new Date().toISOString(), snapshots: [] }; + addSnapshot(entry, snapshotOf(b)); + c.accounts.push(entry); + write(db); + return { username: u, name: entry.name }; +} + +export function remove(client, username) { + checkClient(client); + const db = read(); + const u = handle(username); + const before = db[client]?.accounts?.length || 0; + if (db[client]) db[client].accounts = db[client].accounts.filter((a) => a.username !== u); + if ((db[client]?.accounts?.length || 0) === before) throw err("Not tracked.", 404); + write(db); +} + +/** Refresh every tracked account for one client (or all clients). One snapshot per account per day. */ +export async function refresh(client) { + const db = read(); + const slugs = client ? [checkClient(client)] : Object.keys(db); + const errors = []; + for (const slug of slugs) { + const c = db[slug]; + if (!c?.accounts?.length) continue; + let igId; + try { igId = await viewerIgId(slug); } catch (e) { errors.push(`${slug}: ${e.message}`); continue; } + for (const a of c.accounts) { + try { const b = await lookup(igId, a.username); a.name = b.name || a.name; addSnapshot(a, snapshotOf(b)); } + catch (e) { errors.push(`${slug} @${a.username}: ${e.message}`); } + } + } + write(db); + return { refreshed: slugs.length, errors }; +} + +const engagement = (m) => (m.likes ?? 0) + (m.comments ?? 0); +/** Numbers for one account from its latest posts and snapshot history. */ +function stats({ followers, media, history }) { + const now = Date.now(); + const recent = media.filter((m) => m.at && now - new Date(m.at) <= 28 * 86400000); + const last12 = media.slice(0, 12); + // Median, not mean: one viral post would otherwise make a typical post look many times better than it is. + const sorted = last12.map(engagement).sort((x, y) => x - y); + const avgEng = sorted.length ? Math.round(sorted.length % 2 ? sorted[(sorted.length - 1) / 2] : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2) : null; + const mix = {}; + for (const m of media.slice(0, 25)) mix[m.type] = (mix[m.type] || 0) + 1; + const monthAgo = new Date(now - 30 * 86400000).toISOString().slice(0, 10); + const base = history.find((h) => h.date >= monthAgo && h.followers != null) || null; + return { + followers, + growth30: base && followers != null && base.date !== today() ? followers - base.followers : null, + growthSince: base && base.date !== today() ? base.date : null, + postsPerWeek: media.length ? Math.round((recent.length / 4) * 10) / 10 : null, + avgEngagement: avgEng, + engagementRate: avgEng != null && followers ? Math.round((avgEng / followers) * 10000) / 100 : null, + formatMix: mix, + topPosts: [...media].sort((a, b) => engagement(b) - engagement(a)).slice(0, 3), + }; +} + +/** + * Comparison for a client: their own account (from the dashboard snapshot passed in) plus every tracked competitor. + * own: { username, followers, posts:[{id,timestamp,like_count,comments_count,media_type,media_product_type,permalink,caption}], history:[{date, followers}] } + */ +export function compare(client, own) { + checkClient(client); + const accounts = (read()[client]?.accounts || []).map((a) => { + const latest = a.snapshots?.at(-1) || {}; + return { + username: a.username, name: a.name, source: a.source, note: a.note, addedBy: a.addedBy, addedAt: a.addedAt, lastChecked: latest.date || null, + ...stats({ followers: latest.followers ?? null, media: latest.media || [], history: a.snapshots || [] }), + series: (a.snapshots || []).filter((s) => s.followers != null).map((s) => ({ date: s.date, followers: s.followers })), + }; + }); + let you = null; + if (own) { + const media = (own.posts || []).map((p) => ({ id: p.id, at: p.timestamp, likes: p.like_count ?? null, comments: p.comments_count ?? null, type: p.media_product_type === "REELS" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : p.media_type === "VIDEO" ? "Video" : "Image", permalink: p.permalink, caption: (p.caption || "").slice(0, 200) })); + you = { username: own.username, own: true, ...stats({ followers: own.followers ?? null, media, history: own.history || [] }), series: (own.history || []).filter((h) => h.followers != null) }; + } + return { you, competitors: accounts }; +} diff --git a/connector/src/library.js b/connector/src/library.js index c1561a8..eb1eb70 100644 --- a/connector/src/library.js +++ b/connector/src/library.js @@ -53,13 +53,13 @@ function validateFiles(format, files) { if (format === "CAROUSEL" && (files.length < 2 || files.length > 10)) throw err("A carousel needs 2–10 files."); } -export function createItem({ client, title, format = "IMAGE", files = [], captionIdea = "", notes = "", taskRef = "", forDate = "", uploadedBy = "team" }) { +export function createItem({ client, title, format = "IMAGE", files = [], captionIdea = "", notes = "", taskRef = "", forDate = "", uploadedBy = "team", planItem = "" }) { checkClient(client); if (!FORMATS.includes(format)) throw err("Unknown format."); validateFiles(format, files); const item = { id: crypto.randomUUID().slice(0, 8), client, title: String(title || "").trim() || path.basename(files[0]).replace(/^\d+-\w{4}-/, ""), - format, files, captionIdea, notes, taskRef, forDate, status: "submitted", version: 1, + format, files, captionIdea, notes, taskRef, forDate, planItem: String(planItem || ""), status: "submitted", version: 1, uploadedBy, createdAt: now(), updatedAt: now(), history: [{ at: now(), by: uploadedBy, action: "submitted" }], postIds: [], }; const all = read(); all.push(item); write(all); return item; diff --git a/connector/src/meta.js b/connector/src/meta.js index 8bfac9d..06213b2 100644 --- a/connector/src/meta.js +++ b/connector/src/meta.js @@ -1,16 +1,33 @@ // Thin Meta Graph API client for Facebook Pages + Instagram (via Facebook Login / System User token). import { loadEnv } from "./env.js"; +import * as vault from "./platforms/vault.js"; loadEnv(); const VERSION = process.env.META_API_VERSION || "v23.0"; const BASE = `https://graph.facebook.com/${VERSION}`; +// The agency's Meta system user token: from .env, or saved in Settings (encrypted in the vault) for hosted workspaces. function systemToken() { - const t = process.env.META_SYSTEM_USER_TOKEN; - if (!t || t.startsWith("PASTE")) { - throw new Error("META_SYSTEM_USER_TOKEN is missing. Put it in the .env file in the project root."); - } - return t; + const env = process.env.META_SYSTEM_USER_TOKEN; + if (env && !env.startsWith("PASTE")) return env; + const saved = vault.appCredentials("meta"); + if (saved?.clientSecret) return saved.clientSecret; + throw new Error("Meta isn't connected. An admin adds the Meta system user token in Settings → Meta (or META_SYSTEM_USER_TOKEN in .env)."); +} +/** Save the system user token from Settings; checks it works first. */ +export async function saveSystemToken(token, by) { + const t = String(token || "").trim(); + if (!/^EA[A-Za-z0-9]{20,}$/.test(t)) throw Object.assign(new Error("That doesn't look like a Meta access token (it starts with EA…)."), { status: 400 }); + const r = await fetch(`${BASE}/me?fields=id,name`, { headers: { Authorization: `Bearer ${t}` } }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw Object.assign(new Error(`Meta didn't accept the token: ${j.error?.message || r.status}`), { status: 400 }); + vault.saveApp("meta", { clientId: j.name || j.id, clientSecret: t }, by); + accountCache = null; + return { name: j.name || j.id }; +} +export function metaSource() { + if (process.env.META_SYSTEM_USER_TOKEN && !process.env.META_SYSTEM_USER_TOKEN.startsWith("PASTE")) return "env"; + return vault.appCredentials("meta") ? "settings" : null; } export async function graph(pathname, params = {}, { method = "GET", token } = {}) { @@ -126,7 +143,7 @@ const days = (n) => Math.floor(Date.now() / 1000) - n * 86400; export const IG_ACCOUNT_METRICS = ["reach", "views", "accounts_engaged", "total_interactions", "likes", "comments", "shares", "saves", "follows_and_unfollows", "profile_links_taps"]; export async function igAccountInsights(igId, { sinceDays = 28, metrics = IG_ACCOUNT_METRICS } = {}) { - const profile = await graph(igId, { fields: "id,username,name,biography,followers_count,follows_count,media_count,website" }); + const profile = await graph(igId, { fields: "id,username,name,biography,followers_count,follows_count,media_count,website,profile_picture_url" }); // total_value metrics allow max 30-day windows const window = { period: "day", metric_type: "total_value", since: days(Math.min(sinceDays, 30)), until: days(0) }; const plain = metrics.filter((m) => m !== "follows_and_unfollows"); @@ -148,7 +165,7 @@ export async function igAccountInsights(igId, { sinceDays = 28, metrics = IG_ACC export async function igRecentMedia(igId, { limit = 12, withInsights = true } = {}) { const r = await graph(`${igId}/media`, { - fields: "id,caption,media_type,media_product_type,permalink,timestamp,like_count,comments_count", + fields: "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count", limit, }); const media = r.data || []; @@ -204,7 +221,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) { needPage(account); const r = await graph( `${account.page_id}/posts`, - { fields: "id,message,created_time,permalink_url,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit }, + { fields: "id,message,created_time,permalink_url,full_picture,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit }, { token: account.page_token } ); return (r.data || []).map((p) => ({ @@ -212,6 +229,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) { created_time: p.created_time, permalink_url: p.permalink_url, message: p.message, + picture: p.full_picture || null, reactions: p.reactions?.summary?.total_count ?? 0, comments: p.comments?.summary?.total_count ?? 0, shares: p.shares?.count ?? 0, diff --git a/connector/src/plan.js b/connector/src/plan.js new file mode 100644 index 0000000..56e39bd --- /dev/null +++ b/connector/src/plan.js @@ -0,0 +1,128 @@ +// Content plan: what each client will post and when, before it exists as finished work. +// Flow per item: idea → brief → production → ready. Once scheduled it lives in the queue (schedule.js); +// the calendar view merges plan items, library items with a date, and queued/published posts. +// Shared by the dashboard (calendar UI) and the MCP connector (weekly-plan skill adds items). +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { PROJECT_ROOT } from "./env.js"; +import * as sched from "./schedule.js"; +import * as lib from "./library.js"; + +const FILE = process.env.QUORLA_PLAN_FILE || path.join(PROJECT_ROOT, "data", "plan.json"); +const CLIENTS = path.join(PROJECT_ROOT, "clients"); +fs.mkdirSync(path.dirname(FILE), { 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); }; +const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); +const now = () => new Date().toISOString(); + +export const STAGES = ["idea", "brief", "production", "ready"]; +export const FORMATS = ["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +function checkClient(slug) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || !fs.existsSync(path.join(CLIENTS, slug))) throw err("Unknown client.", 404); + return slug; +} +function clean(patch) { + const out = {}; + if ("title" in patch) { out.title = String(patch.title || "").trim().slice(0, 140); if (!out.title) throw err("Give the item a title."); } + if ("date" in patch) { if (!DATE_RE.test(String(patch.date))) throw err("Date must be YYYY-MM-DD."); out.date = patch.date; } + if ("time" in patch) { const t = String(patch.time || ""); if (t && !/^\d{2}:\d{2}$/.test(t)) throw err("Time must be HH:MM."); out.time = t; } + if ("status" in patch) { if (!STAGES.includes(patch.status)) throw err(`Status must be one of: ${STAGES.join(", ")}.`); out.status = patch.status; } + if ("format" in patch) { if (!FORMATS.includes(patch.format)) throw err(`Format must be one of: ${FORMATS.join(", ")}.`); out.format = patch.format; } + if ("platforms" in patch) out.platforms = (patch.platforms || []).map(String).slice(0, 10); + for (const k of ["brief", "caption", "pillar", "assignee", "notes", "taskRef", "libraryItem"]) if (k in patch) out[k] = String(patch[k] ?? "").slice(0, 5000); + return out; +} + +export function listItems({ client, from, to } = {}) { + return read().filter((i) => (!client || i.client === client) && (!from || i.date >= from) && (!to || i.date <= to)); +} + +export function createItem(data, by = "team") { + const client = checkClient(data.client); + const fields = clean({ title: data.title, date: data.date, status: data.status || "idea", format: data.format || "IMAGE", platforms: data.platforms || [], time: data.time || "", brief: data.brief || "", caption: data.caption || "", pillar: data.pillar || "", assignee: data.assignee || "", notes: data.notes || "" }); + const item = { id: crypto.randomUUID().slice(0, 8), client, ...fields, createdBy: by, createdAt: now(), updatedAt: now() }; + const items = read(); items.push(item); write(items); + return item; +} + +export function updateItem(id, patch, by = "team") { + const items = read(); const i = items.find((x) => x.id === id); + if (!i) throw err("Plan item not found.", 404); + Object.assign(i, clean(patch), { updatedBy: by, updatedAt: now() }); + write(items); + return i; +} + +export function removeItem(id) { + const items = read(); const n = items.length; + const kept = items.filter((x) => x.id !== id); + if (kept.length === n) throw err("Plan item not found.", 404); + write(kept); +} + +/** Local calendar date (YYYY-MM-DD) and time of an instant in a time zone. */ +function localParts(iso, tz) { + const p = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }) + .formatToParts(new Date(iso)).reduce((o, x) => ((o[x.type] = x.value), o), {}); + return { date: `${p.year}-${p.month}-${p.day}`, time: `${p.hour === "24" ? "00" : p.hour}:${p.minute}` }; +} + +/** + * Everything on a client's calendar between two dates (inclusive, client's local dates): + * plan items (kind "plan"), library items with a target date ("library"), and queued/published posts ("post"). + * Each entry has a unified `stage`: idea | brief | production | ready | scheduled | published | failed. + */ +export function calendar({ client, from, to }) { + checkClient(client); + if (!DATE_RE.test(from) || !DATE_RE.test(to)) throw err("from and to must be YYYY-MM-DD."); + const tz = sched.clientInfo(client).timezone; + const libItems = lib.listItems({ client }); + const activeLib = new Set(libItems.filter((l) => l.status !== "archived").map((l) => l.id)); + // A plan item whose work was uploaded shows as the library card (with the plan's title) instead of twice. + const plans = listItems({ client, from, to }); + const planById = Object.fromEntries(listItems({ client }).map((i) => [i.id, i])); + const out = plans.filter((i) => !(i.libraryItem && activeLib.has(i.libraryItem))).map((i) => ({ ...i, kind: "plan", stage: i.status, editable: true })); + + for (const l of libItems) { + if (!l.forDate || l.forDate < from || l.forDate > to || ["scheduled", "archived"].includes(l.status)) continue; + out.push({ + id: l.id, kind: "library", client, title: planById[l.planItem]?.title || l.title, date: l.forDate, time: planById[l.planItem]?.time || "", format: l.format, platforms: planById[l.planItem]?.platforms || [], + assignee: planById[l.planItem]?.assignee || l.uploadedBy, planItem: l.planItem || null, + stage: l.status === "approved" ? "ready" : "production", detail: l.status === "changes" ? "Changes requested" : l.status === "submitted" ? "Waiting for review" : "Approved in the library", + caption: l.captionIdea || "", files: l.files || [], editable: true, + }); + } + for (const p of sched.listPosts({ client })) { + if (p.status === "cancelled") continue; + const at = localParts(p.publishAt, tz); + if (at.date < from || at.date > to) continue; + const stage = p.status === "published" || p.status === "partial" ? "published" : p.status === "failed" ? "failed" : "scheduled"; + out.push({ + id: p.id, kind: "post", client, title: (p.options?.youtube?.title || p.caption || "").split("\n")[0].slice(0, 90) || "Untitled post", + date: at.date, time: at.time, format: p.type, platforms: p.platforms, stage, + detail: p.status === "draft" ? "Draft, waiting for approval" : p.status === "approved" ? "Approved, will publish" : p.status === "partial" ? "Partly published" : null, + caption: p.caption || "", files: p.media || [], editable: ["draft", "failed"].includes(p.status), postStatus: p.status, + }); + } + const order = { published: 0, failed: 1, scheduled: 2, ready: 3, production: 4, brief: 5, idea: 6 }; + return { timezone: tz, items: out.sort((a, b) => a.date.localeCompare(b.date) || (a.time || "99").localeCompare(b.time || "99") || order[a.stage] - order[b.stage]) }; +} + +/** Move anything on the calendar to another day (keeps the time). Posts can only move while they're drafts. */ +export function moveEntry(kind, id, date, by = "team") { + if (!DATE_RE.test(String(date))) throw err("Date must be YYYY-MM-DD."); + if (kind === "plan") return updateItem(id, { date }, by); + if (kind === "library") return lib.updateItem(id, { forDate: date }, by); + if (kind === "post") { + const p = sched.getPost(id); + if (!["draft", "failed"].includes(p.status)) throw err("Only drafts can be moved. Cancel the approval first."); + const { time } = localParts(p.publishAt, sched.clientInfo(p.client).timezone); + return sched.updatePost(id, { localTime: `${date} ${time}` }, by); + } + throw err("Unknown calendar item."); +} diff --git a/connector/src/platforms/gbp.js b/connector/src/platforms/gbp.js index 5a1c453..8511044 100644 --- a/connector/src/platforms/gbp.js +++ b/connector/src/platforms/gbp.js @@ -47,6 +47,28 @@ export default { return out; }, + /** Latest reviews on the chosen location (Business Profile API v4). */ + async reviews(tokens, target, { pageSize = 30 } = {}) { + const STARS = { ONE: 1, TWO: 2, THREE: 3, FOUR: 4, FIVE: 5 }; + const { data } = await request("Google Business Profile", `https://mybusiness.googleapis.com/v4/${target.id}/reviews`, { token: tokens.accessToken, query: { pageSize, orderBy: "updateTime desc" } }); + return { + average: data.averageRating ?? null, total: data.totalReviewCount ?? null, + reviews: (data.reviews || []).map((r) => ({ + id: r.name, author: r.reviewer?.isAnonymous ? "A Google user" : r.reviewer?.displayName || "A Google user", avatar: r.reviewer?.profilePhotoUrl || null, + stars: STARS[r.starRating] ?? null, text: r.comment || "", at: r.createTime, updatedAt: r.updateTime, + reply: r.reviewReply ? { text: r.reviewReply.comment, at: r.reviewReply.updateTime } : null, + })), + }; + }, + + async replyReview(tokens, reviewName, comment) { + const text = String(comment || "").trim(); + if (!text) throw new Error("Write a reply first."); + if (text.length > 4096) throw new Error("Google allows replies up to 4,096 characters."); + const { data } = await request("Google Business Profile", `https://mybusiness.googleapis.com/v4/${reviewName}/reply`, { token: tokens.accessToken, method: "PUT", json: { comment: text } }); + return { text: data.comment, at: data.updateTime }; + }, + async publish({ tokens, conn, post, opts, caption }) { const location = conn.target?.id; if (!location) throw new Error("Google Business Profile: pick the business location on the Connections page first."); diff --git a/connector/src/platforms/index.js b/connector/src/platforms/index.js index 4926cb2..e426803 100644 --- a/connector/src/platforms/index.js +++ b/connector/src/platforms/index.js @@ -97,6 +97,44 @@ export async function setTarget(connId, targetId) { return vault.updateConnection(connId, { target: t }); } +/** Per-post numbers for platforms whose adapter can read them (YouTube today). ids: the platform's post ids. */ +export async function postStats(platform, client, ids) { + const a = adapter(platform); + if (!a.stats || !ids.length) return {}; + const conn = vault.connectionFor(client, platform); + if (!conn) return {}; + const { tokens } = await tokensFor(conn.id); + if (platform === "youtube") quota.ensure("youtube", null, "read"); + const out = await a.stats(tokens, ids); + if (platform === "youtube") quota.record("youtube", null, "read"); + return out; +} + +/** Google reviews for a client's Business Profile location. */ +export async function gbpReviews(client) { + const conn = vault.connectionFor(client, "gbp"); + if (!conn) throw err("Google Business Profile isn't connected for this client. Connect it on the Connections page."); + if (!conn.target) throw err("Choose the business location on the Connections page first."); + const { tokens } = await tokensFor(conn.id); + return { location: conn.target.name, ...(await adapter("gbp").reviews(tokens, conn.target)) }; +} +export async function gbpReply(client, reviewId, text) { + const conn = vault.connectionFor(client, "gbp"); + if (!conn?.target || !String(reviewId).startsWith(`${conn.target.id}/reviews/`)) throw err("That review isn't on this client's location."); + const { tokens } = await tokensFor(conn.id); + return adapter("gbp").replyReview(tokens, reviewId, text); +} +/** Latest YouTube comments on the client's channel. */ +export async function youtubeComments(client) { + const conn = vault.connectionFor(client, "youtube"); + if (!conn) throw err("YouTube isn't connected for this client."); + const { tokens } = await tokensFor(conn.id); + quota.ensure("youtube", null, "read"); + const out = await adapter("youtube").comments(tokens, conn.account); + quota.record("youtube", null, "read"); + return { channel: conn.account?.name, comments: out }; +} + /** Re-read the account (name, followers) to check the connection still works. */ export async function checkConnection(connId) { const { conn, tokens } = await tokensFor(connId); diff --git a/connector/src/platforms/youtube.js b/connector/src/platforms/youtube.js index 255cf9c..62720da 100644 --- a/connector/src/platforms/youtube.js +++ b/connector/src/platforms/youtube.js @@ -32,6 +32,25 @@ export default { }; }, + /** Latest comments across the channel's videos (read-only: replying needs a broader scope, so reply on YouTube). */ + async comments(tokens, account, { max = 30 } = {}) { + const { data } = await request("YouTube", `${API}/commentThreads`, { token: tokens.accessToken, query: { part: "snippet", allThreadsRelatedToChannelId: account.id, maxResults: max, order: "time" } }); + return (data.items || []).map((t) => { + const c = t.snippet?.topLevelComment?.snippet || {}; + return { id: t.id, author: c.authorDisplayName, avatar: c.authorProfileImageUrl || null, text: c.textOriginal || "", at: c.publishedAt, likes: c.likeCount ?? 0, replies: t.snippet?.totalReplyCount ?? 0, videoId: t.snippet?.videoId, url: `https://www.youtube.com/watch?v=${t.snippet?.videoId}&lc=${t.id}` }; + }); + }, + + /** View/like/comment counts for uploaded videos (1 quota unit per call, up to 50 ids). */ + async stats(tokens, ids) { + if (!ids.length) return {}; + const { data } = await request("YouTube", `${API}/videos`, { token: tokens.accessToken, query: { part: "statistics,snippet", id: ids.slice(0, 50).join(",") } }); + return Object.fromEntries((data.items || []).map((v) => [v.id, { + views: Number(v.statistics?.viewCount ?? 0), likes: Number(v.statistics?.likeCount ?? 0), comments: Number(v.statistics?.commentCount ?? 0), + thumb: v.snippet?.thumbnails?.medium?.url || v.snippet?.thumbnails?.default?.url || null, + }])); + }, + async publish({ tokens, post, opts, caption }) { const src = (post.media || [])[0]; if (!src || !isVideo(src)) throw new Error("YouTube needs one video file."); diff --git a/connector/src/schedule.js b/connector/src/schedule.js index bf88131..2a35c03 100644 --- a/connector/src/schedule.js +++ b/connector/src/schedule.js @@ -4,10 +4,12 @@ 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"; import * as quota from "./platforms/quota.js"; +import * as brandkit from "./brandkit.js"; const META_PLATFORMS = ["instagram", "facebook"]; export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS]; @@ -21,6 +23,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 }); @@ -86,6 +91,19 @@ function quotaAccount(client, platform) { } /** Warnings (not errors) when a day's planned posts exceed a platform's limit; extra posts then wait for the reset. */ +/** Brand-kit and writing checks on every caption a post will use. Naming the agency is refused outright. */ +function brandWarnings(post) { + const captions = [[null, post.caption], ["facebook", post.fbCaption], ...Object.entries(post.options || {}).map(([pl, o]) => [pl, o?.caption])].filter(([, c]) => c); + const out = []; + for (const [pl, c] of captions) { + for (const w of brandkit.checkCaption(post.client, c, { platforms: pl ? [pl] : post.platforms })) { + if (/never name the agency/.test(w.text)) throw err(`${pl ? `${pl} caption: ` : ""}${w.text}`); + out.push(`${pl ? `${pl} caption: ` : ""}${w.text}`); + } + } + return [...new Set(out)]; +} + function quotaWarnings(post, items) { const at = new Date(post.publishAt).getTime(); const out = []; @@ -133,8 +151,9 @@ export function createPost({ client, platforms = ["instagram"], type = "IMAGE", results: {}, errors: {}, }; const items = read(); - post.warnings = quotaWarnings(post, items); + post.warnings = [...quotaWarnings(post, items), ...brandWarnings(post)]; items.push(post); write(items); + emit("draft_created", post); return post; } @@ -146,10 +165,33 @@ export function updatePost(id, patch, by = "team") { for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p); if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); } p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString(); - p.warnings = quotaWarnings(p, items); + p.warnings = [...quotaWarnings(p, items), ...brandWarnings(p)]; + // Changed after the client looked at it: ask them again. + if (p.clientReview && p.clientReview.status !== "pending") p.clientReview = { ...p.clientReview, status: "pending", previous: p.clientReview.status, requestedAt: p.updatedAt, note: "", by: null }; write(items); return p; } +/** Put a draft in front of the client (their approval link shows it). The admin's own approval is still needed to publish. */ +export function requestClientReview(id, by = "team") { + const items = read(); const p = items.find((x) => x.id === id); + 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); emit("client_review_requested", p); return p; +} + +/** The client's answer from their approval link: approved, or changes with a note. */ +export function clientDecision(id, client, { decision, note = "", name = "" }) { + if (!["approved", "changes"].includes(decision)) throw err("Choose approve or request changes."); + if (decision === "changes" && !String(note).trim()) throw err("Tell us what to change."); + const items = read(); const p = items.find((x) => x.id === id && x.client === client); + if (!p || !p.clientReview) throw err("Post not found.", 404); + 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); 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. */ export async function approvePost(id, by) { if (!by) throw err("Approval needs the name of the person approving."); @@ -254,6 +296,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/connector/src/server.js b/connector/src/server.js index 5358acd..82053c7 100644 --- a/connector/src/server.js +++ b/connector/src/server.js @@ -10,6 +10,9 @@ import { PROJECT_ROOT } from "./env.js"; import * as meta from "./meta.js"; import * as sched from "./schedule.js"; import * as lib from "./library.js"; +import * as plan from "./plan.js"; +import * as competitors from "./competitors.js"; +import * as brandkit from "./brandkit.js"; import * as extra from "./platforms/index.js"; const server = new McpServer({ name: "social-manager", version: "0.1.0" }); @@ -184,4 +187,39 @@ server.tool("schedule_from_library", "Turn an APPROVED library item into a DRAFT caption: z.string().optional(), fb_caption: z.string().optional(), collaborators: z.array(z.string()).default([]), options }, wrap(async (a) => lib.scheduleItem(a.id, { platforms: a.platforms, localTime: a.local_time, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, options: a.options }, "agent"))); +server.tool("content_plan", "A client's content calendar between two dates: plan items (idea/brief/production/ready), library items with a target date, and queued/published posts. Check this before planning so nothing is doubled.", + { client: z.string(), from: z.string().describe("YYYY-MM-DD"), to: z.string().describe("YYYY-MM-DD") }, + wrap(async ({ client, from, to }) => plan.calendar({ client, from, to }))); + +server.tool("plan_add", "Add items to a client's content calendar (the weekly-plan skill uses this). Each item is a planned post, not a scheduled one: it starts as an idea or brief for the team to produce. Never publishes.", + { client: z.string(), items: z.array(z.object({ + date: z.string().describe("YYYY-MM-DD in the client's time zone"), time: z.string().optional().describe("HH:MM, optional"), + title: z.string().describe("Short working title shown on the calendar card"), + format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).default("IMAGE"), + platforms: z.array(z.string()).default([]), status: z.enum(["idea", "brief", "production", "ready"]).default("brief"), + brief: z.string().default("").describe("Creative brief for the designer/editor"), caption: z.string().default("").describe("Draft caption"), + pillar: z.string().default(""), assignee: z.string().default(""), + })).min(1).max(30) }, + wrap(async ({ client, items }) => items.map((i) => plan.createItem({ client, ...i }, "agent")))); + +server.tool("plan_update", "Update a content-plan item (status, date, brief, caption, assignee...).", + { id: z.string(), patch: z.object({ date: z.string().optional(), time: z.string().optional(), title: z.string().optional(), status: z.enum(["idea", "brief", "production", "ready"]).optional(), + format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).optional(), platforms: z.array(z.string()).optional(), brief: z.string().optional(), caption: z.string().optional(), assignee: z.string().optional() }) }, + wrap(async ({ id, patch }) => plan.updateItem(id, patch, "agent"))); + +server.tool("competitors", "The Instagram competitors tracked for a client, with followers, growth, posts per week, average engagement, format mix and top posts (the dashboard's Competitors page). Use in audits instead of one-off lookups.", + { client: z.string() }, + wrap(async ({ client }) => competitors.compare(client, null))); + +server.tool("competitor_add", "Start tracking an Instagram competitor for a client (public Business/Creator accounts only). Use for the 3–5 closest local competitors you identify in an audit; say why in note.", + { client: z.string(), username: z.string(), note: z.string().default("") }, + wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note }))); + +server.tool("brand_kit", "A client's saved hashtag sets, approved phrases/CTAs and words to avoid. Read it before writing captions for that client.", + { client: z.string() }, wrap(async ({ client }) => brandkit.get(client))); + +server.tool("caption_check", "Check a caption against the client's brand kit and our writing rules before scheduling (agency name, avoid list, generic phrases, hashtag count, platform length).", + { client: z.string(), caption: z.string(), platforms: z.array(z.string()).default([]) }, + wrap(async ({ client, caption, platforms }) => brandkit.checkCaption(client, caption, { platforms }))); + await server.connect(new StdioServerTransport()); diff --git a/connector/test/brandkit.test.mjs b/connector/test/brandkit.test.mjs new file mode 100644 index 0000000..ee24266 --- /dev/null +++ b/connector/test/brandkit.test.mjs @@ -0,0 +1,24 @@ +// Brand kit and caption checks (offline, temp file). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +process.env.QUORLA_BRANDKIT_FILE = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "quorla-bk-")), "bk.json"); +const bk = await import("../src/brandkit.js"); +const { PROJECT_ROOT } = await import("../src/env.js"); +const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && !/metatron/i.test(d) && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json"))); + +test("brand kit: saves clean hashtag sets and lists; caption checks", { skip: !client && "no client folder" }, () => { + const k = bk.save(client, { hashtagSets: [{ name: "Local", tags: "waterloo, #kitchener #KW bad-tag!" }], phrases: "Order online\n\nBook a table", avoid: "cheap\nbest in town" }); + assert.deepEqual(k.hashtagSets[0].tags, ["#waterloo", "#kitchener", "#KW"]); + assert.deepEqual(k.phrases, ["Order online", "Book a table"]); + const w = bk.checkCaption(client, "The best in town, made by Quorla. Unlock flavour! " + "#a ".repeat(12), { platforms: ["threads"] }).map((x) => x.level + ":" + x.text.slice(0, 25)); + assert.ok(w.some((x) => x.startsWith("bad:Mentions “quorla”"))); + assert.ok(w.some((x) => x.startsWith("warn:Uses “best in town”"))); + assert.ok(w.some((x) => x.startsWith("warn:“unlock”"))); + assert.ok(w.some((x) => x.startsWith("warn:12 hashtags"))); + assert.equal(bk.checkCaption(client, "x".repeat(600), { platforms: ["threads"] })[0].level, "bad"); + assert.deepEqual(bk.checkCaption(client, "Fresh focaccia at 8. First 20 people get a free cortado.", { platforms: ["instagram"] }), []); +}); diff --git a/connector/test/competitors.test.mjs b/connector/test/competitors.test.mjs new file mode 100644 index 0000000..ba6a896 --- /dev/null +++ b/connector/test/competitors.test.mjs @@ -0,0 +1,33 @@ +// Competitor comparison maths, offline (no Instagram calls): median engagement, rate, growth, format mix, top posts. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-comp-")); +process.env.QUORLA_COMPETITORS_FILE = path.join(TMP, "competitors.json"); +const { PROJECT_ROOT } = await import("../src/env.js"); +const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json"))); + +const day = (n) => new Date(Date.now() - n * 86400000); +const media = [5, 10, 20, 40, 1000].map((likes, i) => ({ id: `m${i}`, at: day(i * 3).toISOString(), likes, comments: 0, type: i % 2 ? "Reel" : "Image", permalink: `https://x/${i}` })); + +test("compare: median engagement ignores one viral post; growth from history", { skip: !client && "no client folder" }, async () => { + fs.writeFileSync(process.env.QUORLA_COMPETITORS_FILE, JSON.stringify({ + [client]: { accounts: [{ username: "rival", name: "Rival", source: "manual", snapshots: [ + { date: day(20).toISOString().slice(0, 10), followers: 900 }, + { date: day(0).toISOString().slice(0, 10), followers: 1000, media }, + ] }] }, + })); + const c = await import("../src/competitors.js"); + const r = c.compare(client, { username: "me", followers: 500, posts: [], history: [] }).competitors[0]; + assert.equal(r.avgEngagement, 20, "median of 5,10,20,40,1000"); + assert.equal(r.engagementRate, 2); + assert.equal(r.growth30, 100); + assert.equal(r.postsPerWeek, 1.3, "5 posts in 28 days"); + assert.deepEqual(r.formatMix, { Image: 3, Reel: 2 }); + assert.equal(r.topPosts[0].likes, 1000); + assert.equal(c.listTracked(client)[0].username, "rival"); + await assert.rejects(c.add(client, "not a handle!!"), /Instagram handle/); +}); diff --git a/connector/test/control.test.mjs b/connector/test/control.test.mjs new file mode 100644 index 0000000..dbcc77b --- /dev/null +++ b/connector/test/control.test.mjs @@ -0,0 +1,64 @@ +// Hosted control centre: Stripe webhook signatures, sign-up, subscription lifecycle, provisioning tasks. +// Temp data folders only; Stripe is simulated. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-ctl-")); +Object.assign(process.env, { + QUORLA_CONTROL_DIR: path.join(TMP, "control"), QUORLA_DASHBOARD_DATA: path.join(TMP, "dash"), QUORLA_CONTROL: "true", + STRIPE_SECRET_KEY: "sk_test_x", STRIPE_PRICE_ID: "price_x", STRIPE_WEBHOOK_SECRET: "whsec_test", PROVISION_TOKEN: "p".repeat(32), SMTP_HOST: "", +}); +const control = await import("../../dashboard/server/control.js"); + +const sign = (body, secret = "whsec_test", t = Math.floor(Date.now() / 1000)) => `t=${t},v1=${crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex")}`; + +test("webhook signatures: valid accepted; wrong secret, old or missing rejected", () => { + const body = JSON.stringify({ type: "ping" }); + assert.equal(control.verifyWebhook(body, sign(body)).type, "ping"); + assert.throws(() => control.verifyWebhook(body, sign(body, "whsec_other")), /doesn't match/); + assert.throws(() => control.verifyWebhook(body, sign(body, "whsec_test", Math.floor(Date.now() / 1000) - 1000)), /too old/); + assert.throws(() => control.verifyWebhook(body, ""), /Bad signature/); +}); + +test("sign-up → Stripe Checkout → paid → provisioning → running → billing states", async () => { + const realFetch = globalThis.fetch; + let sent; + globalThis.fetch = async (url, init) => { sent = { url: String(url), body: String(init.body) }; return new Response(JSON.stringify({ url: "https://checkout.stripe.test/s1" }), { status: 200 }); }; + try { + await assert.rejects(control.signup({ agencyName: "A", ownerEmail: "x", slug: "ab" }, "https://app.test"), /valid work email/); + await assert.rejects(control.signup({ agencyName: "A", ownerEmail: "a@b.co", slug: "www" }, "https://app.test"), /Workspace address/); + const r = await control.signup({ agencyName: "Northstar Digital", ownerName: "Ana", ownerEmail: "ana@northstar.test", slug: "northstar" }, "https://app.test"); + assert.equal(r.url, "https://checkout.stripe.test/s1"); + assert.match(sent.url, /checkout\/sessions$/); + assert.match(decodeURIComponent(sent.body), /metadata\[slug\]=northstar/); + } finally { globalThis.fetch = realFetch; } + + assert.equal(control.list()[0].status, "pending_payment"); + assert.deepEqual(control.tasks(), [], "nothing to do before payment"); + + await control.onStripeEvent({ type: "checkout.session.completed", data: { object: { client_reference_id: "northstar", metadata: { slug: "northstar" }, customer: "cus_1", subscription: "sub_1" } } }); + const [task] = control.tasks(); + assert.equal(task.desired, "running"); + assert.equal(task.port, 4400); + assert.ok(task.setupToken); + + assert.throws(() => control.checkProvisionToken("Bearer nope"), /Not allowed/); + control.checkProvisionToken(`Bearer ${"p".repeat(32)}`); + const t = await control.report("northstar", { actual: "running" }); + assert.equal(t.status, "active"); + assert.equal(t.setupToken, undefined, "setup token never leaves the control centre in listings"); + assert.deepEqual(control.tasks(), []); + + await control.onStripeEvent({ type: "invoice.payment_failed", data: { object: { subscription: "sub_1" } } }); + assert.equal(control.list()[0].status, "past_due"); + await control.onStripeEvent({ type: "customer.subscription.updated", data: { object: { id: "sub_1", status: "unpaid", metadata: { slug: "northstar" } } } }); + assert.equal(control.list()[0].status, "suspended"); + assert.equal(control.tasks()[0].desired, "stopped"); + await control.report("northstar", { actual: "stopped" }); + await control.onStripeEvent({ type: "customer.subscription.updated", data: { object: { id: "sub_1", status: "active", metadata: { slug: "northstar" } } } }); + assert.equal(control.tasks()[0].desired, "running", "paying again restarts it"); +}); diff --git a/connector/test/plan.test.mjs b/connector/test/plan.test.mjs new file mode 100644 index 0000000..5b7260e --- /dev/null +++ b/connector/test/plan.test.mjs @@ -0,0 +1,37 @@ +// Content plan: create, validate, show on the calendar, move between days, remove. Uses temp files only. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-plan-")); +process.env.QUORLA_PLAN_FILE = path.join(TMP, "plan.json"); +process.env.QUORLA_SCHEDULE_DIR = path.join(TMP, "schedule"); + +const plan = await import("../src/plan.js"); +const { PROJECT_ROOT } = await import("../src/env.js"); +const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json"))); + +test("plan items: create, validate, calendar, move, remove", { skip: !client && "no client folder" }, () => { + const a = plan.createItem({ client, title: "Behind the counter reel", date: "2031-03-04", format: "REELS", status: "brief", brief: "15s, morning prep" }, "t"); + assert.equal(a.status, "brief"); + assert.throws(() => plan.createItem({ client, title: "", date: "2031-03-04" }), /title/); + assert.throws(() => plan.createItem({ client, title: "x", date: "4 March" }), /YYYY-MM-DD/); + assert.throws(() => plan.updateItem(a.id, { status: "published" }), /Status must be/); + assert.throws(() => plan.createItem({ client: "no-such-client", title: "x", date: "2031-03-04" }), /Unknown client/); + + const cal = plan.calendar({ client, from: "2031-03-01", to: "2031-03-31" }); + const mine = cal.items.find((i) => i.id === a.id); + assert.equal(mine.kind, "plan"); + assert.equal(mine.stage, "brief"); + + plan.moveEntry("plan", a.id, "2031-03-09"); + assert.equal(plan.calendar({ client, from: "2031-03-09", to: "2031-03-09" }).items.find((i) => i.id === a.id).date, "2031-03-09"); + assert.equal(plan.calendar({ client, from: "2031-03-01", to: "2031-03-08" }).items.some((i) => i.id === a.id), false); + + plan.updateItem(a.id, { status: "ready", assignee: "Elena" }); + assert.equal(plan.listItems({ client }).find((i) => i.id === a.id).assignee, "Elena"); + plan.removeItem(a.id); + assert.equal(plan.listItems({ client }).some((i) => i.id === a.id), false); +}); diff --git a/connector/test/platforms.test.mjs b/connector/test/platforms.test.mjs index 3ed6656..8a7cfb8 100644 --- a/connector/test/platforms.test.mjs +++ b/connector/test/platforms.test.mjs @@ -346,3 +346,28 @@ test("platform errors include the code and a plain fix", async () => { route("POST", "https://open.tiktokapis.com/v2/post/publish/video/init/", json({ error: { code: "unaudited_client_can_only_post_to_private_accounts", message: "Please review our integration guidelines" } }, 403)); await assert.rejects(P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "SELF_ONLY" } } }), /Private account.*unaudited_client_can_only_post_to_private_accounts/); }); + +test("Google reviews: list with stars and replies, reply only to this location's reviews", async () => { + reset(); + route("GET", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/reviews", json({ averageRating: 4.6, totalReviewCount: 52, reviews: [ + { name: "accounts/1/locations/5/reviews/r1", reviewer: { displayName: "Sam" }, starRating: "FIVE", comment: "Best croissants", createTime: "2026-10-01T10:00:00Z" }, + { name: "accounts/1/locations/5/reviews/r2", reviewer: { isAnonymous: true }, starRating: "TWO", createTime: "2026-10-02T10:00:00Z", reviewReply: { comment: "Sorry!", updateTime: "2026-10-03T10:00:00Z" } }, + ] })); + const r = await P.gbpReviews("acme"); + assert.equal(r.average, 4.6); + assert.deepEqual(r.reviews.map((x) => [x.stars, x.author, Boolean(x.reply)]), [[5, "Sam", false], [2, "A Google user", true]]); + route("PUT", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/reviews/r1/reply", json({ comment: "Thanks Sam!", updateTime: "2026-10-04T10:00:00Z" })); + assert.equal((await P.gbpReply("acme", "accounts/1/locations/5/reviews/r1", "Thanks Sam!")).text, "Thanks Sam!"); + await assert.rejects(P.gbpReply("acme", "accounts/9/locations/9/reviews/x", "hi"), /isn't on this client's location/); + await assert.rejects(P.gbpReply("acme", "accounts/1/locations/5/reviews/r1", " "), /Write a reply/); +}); + +test("YouTube comments: latest threads on the channel with a link to reply", async () => { + reset(); + route("POST", "https://oauth2.googleapis.com/token", json({ access_token: "yt-new", expires_in: 3600 })); + route("GET", "https://www.googleapis.com/youtube/v3/commentThreads", json({ items: [{ id: "t1", snippet: { videoId: "v1", totalReplyCount: 2, topLevelComment: { snippet: { authorDisplayName: "Ana", textOriginal: "Love this", publishedAt: "2026-10-05T10:00:00Z", likeCount: 3 } } } }] })); + const r = await P.youtubeComments("acme"); + assert.equal(r.comments[0].author, "Ana"); + assert.equal(r.comments[0].url, "https://www.youtube.com/watch?v=v1&lc=t1"); + assert.match(sent(/commentThreads/)[0].url, /allThreadsRelatedToChannelId=/); +}); 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/activity.js b/dashboard/server/activity.js new file mode 100644 index 0000000..5adfa12 --- /dev/null +++ b/dashboard/server/activity.js @@ -0,0 +1,70 @@ +// Activity log: every change someone makes in the dashboard (POST/PUT/PATCH/DELETE that succeeded), +// with secrets masked. Read together with the queue's publish log on the Audit page. +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR } from "./config.js"; +import { PROJECT_ROOT } from "../../connector/src/env.js"; + +const FILE = path.join(DATA_DIR, "activity.jsonl"); +const PUBLISH_LOG = path.join(PROJECT_ROOT, "data", "publish-log.jsonl"); +const SECRET = /pass(word)?|secret|token|api[_-]?key|authorization|cookie/i; +const MAX_BYTES = 20 * 1024 * 1024; + +function mask(v, depth = 0) { + if (depth > 4 || v == null) return v; + if (typeof v === "string") return v.length > 300 ? `${v.slice(0, 300)}…` : v; + if (Array.isArray(v)) return v.slice(0, 20).map((x) => mask(x, depth + 1)); + if (typeof v === "object") return Object.fromEntries(Object.entries(v).slice(0, 40).map(([k, x]) => [k, SECRET.test(k) ? "•••" : mask(x, depth + 1)])); + return v; +} + +/** Express middleware: log successful writes once the response is sent. */ +export function middleware(req, res, next) { + if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") return next(); + res.on("finish", () => { + if (res.statusCode >= 400 || !req.user) return; + // Skip noise: marking notifications read, uploads (body is the file). + if (/\/api\/notifications\/read$|\/upload$/.test(req.path)) return; + const entry = { at: new Date().toISOString(), by: req.user.name || req.user.email, userId: req.user.id, method: req.method, path: req.originalUrl.split("?")[0], body: mask(req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body) ? req.body : undefined), status: res.statusCode }; + try { + if (fs.existsSync(FILE) && fs.statSync(FILE).size > MAX_BYTES) fs.renameSync(FILE, FILE.replace(/\.jsonl$/, `-${Date.now()}.jsonl`)); + fs.appendFileSync(FILE, JSON.stringify(entry) + "\n"); + } catch (e) { console.error("Activity log failed", e.message); } + }); + next(); +} + +const readLines = (f) => { try { return fs.readFileSync(f, "utf8").split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } }; + +// Plain-English description of an API call. +const WHAT = [ + [/^POST \/api\/schedule\/[^/]+\/approve$/, "approved a post"], [/^POST \/api\/schedule\/[^/]+\/cancel$/, "cancelled a post"], + [/^POST \/api\/schedule\/[^/]+\/retry$/, "retried a post"], [/^POST \/api\/schedule\/[^/]+\/send-to-client$/, "sent a post to the client"], + [/^POST \/api\/schedule$/, "created a draft post"], [/^PATCH \/api\/schedule\//, "edited a post"], + [/^POST \/api\/library\/bulk-schedule$/, "bulk-scheduled library items"], [/^POST \/api\/library\/[^/]+\/review$/, "reviewed library work"], + [/^POST \/api\/library\/[^/]+\/schedule$/, "scheduled library work"], [/^POST \/api\/library$/, "uploaded work to the library"], [/^POST \/api\/library\/[^/]+\/archive$/, "archived library work"], + [/^(POST|PATCH|DELETE) \/api\/plan/, "changed the content plan"], [/^POST \/api\/calendar\/move$/, "moved a calendar item"], + [/^(POST|PATCH|DELETE) \/api\/users/, "changed a team member"], [/^POST \/api\/me\/password$/, "changed their password"], + [/^(PUT|DELETE) \/api\/platforms\//, "changed a platform app"], [/^(PATCH|DELETE) \/api\/connections\//, "changed a connection"], [/^POST \/api\/connections\/[^/]+\/check$/, "checked a connection"], + [/^(POST|PATCH|DELETE) \/api\/portal\//, "changed a client approval link"], [/^POST \/api\/inbox\/(reviews\/)?reply$/, "replied to a comment or review"], [/^POST \/api\/inbox\/hide$/, "hid or unhid a comment"], + [/^(POST|DELETE) \/api\/competitors/, "changed tracked competitors"], [/^PUT \/api\/brandkit\//, "edited a brand kit"], [/^PUT \/api\/bio\//, "edited a link-in-bio page"], + [/^POST \/api\/clients\/[^/]+\/monthly\/email$/, "emailed a monthly report"], [/^POST \/api\/clients\/[^/]+\/monthly\/build$/, "created a monthly report"], + [/^POST \/api\/jobs/, "started an AI run"], [/^POST \/api\/team\/tasks/, "changed an Odoo task"], +]; +const describe = (e) => WHAT.find(([re]) => re.test(`${e.method} ${e.path}`))?.[1] || `${e.method} ${e.path.replace(/^\/api\//, "")}`; +const clientOf = (e) => e.body?.client || e.path.match(/\/(?:clients|portal|brandkit|bio|competitors)\/([a-z0-9-]+)/)?.[1] || null; + +/** Activity + publishing events, newest first. */ +export function list({ days = 30, user, client, q, limit = 300 } = {}) { + const since = new Date(Date.now() - days * 86400000).toISOString(); + const acts = readLines(FILE).filter((e) => e.at >= since).map((e) => ({ at: e.at, by: e.by, kind: "action", text: describe(e), client: clientOf(e), detail: e.body ? JSON.stringify(e.body).slice(0, 400) : "" })); + const pubs = readLines(PUBLISH_LOG).filter((e) => e.at >= since).map((e) => ({ + at: e.at, by: e.by || "Quorla", kind: e.action === "failed" ? "error" : "publish", client: e.client || null, + text: e.action === "published" ? `published to ${e.platform}` : e.action === "failed" ? `couldn't publish to ${e.platform}` : e.action === "waiting_for_quota" ? `waiting for ${e.platform}'s daily limit` : e.action.replace(/_/g, " "), + detail: e.error || e.result?.permalink || "", + })); + const needle = String(q || "").toLowerCase(); + return [...acts, ...pubs] + .filter((e) => (!user || e.by === user) && (!client || e.client === client) && (!needle || `${e.text} ${e.detail} ${e.by}`.toLowerCase().includes(needle))) + .sort((a, b) => b.at.localeCompare(a.at)).slice(0, limit); +} diff --git a/dashboard/server/auth.js b/dashboard/server/auth.js index 91544aa..52a8994 100644 --- a/dashboard/server/auth.js +++ b/dashboard/server/auth.js @@ -68,7 +68,13 @@ export function login(req, res) { /** First run only: create the first admin from the sign-in screen when no logins exist. */ export function setupFirstAdmin(req, res) { if (users.hasUsers()) return res.status(403).json({ error: "Setup is already done. Sign in instead." }); - const { email, name, password } = req.body || {}; + const { email, name, password, setupToken } = req.body || {}; + // Hosted workspaces are created with SETUP_TOKEN: only the person with the emailed setup link can make the first admin. + const need = process.env.SETUP_TOKEN || ""; + if (need) { + const a = Buffer.from(String(setupToken || "")), b = Buffer.from(need); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(403).json({ error: "Use the setup link from your welcome email to create the first account." }); + } try { if (!password) throw Object.assign(new Error("Choose a password."), { status: 400 }); const { user } = users.createUser({ email, name, password, role: "admin" }, "first-run setup"); diff --git a/dashboard/server/bio.js b/dashboard/server/bio.js new file mode 100644 index 0000000..2ec8152 --- /dev/null +++ b/dashboard/server/bio.js @@ -0,0 +1,116 @@ +// Link-in-bio page per client: a fast public page (/l/) with the client's links and latest posts. +// Every link goes through /l//go/, which counts the click (per day) and adds UTM tags when enabled. +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { DATA_DIR } from "./config.js"; +import * as clients from "./clients.js"; +import * as metrics from "./metrics.js"; + +const FILE = path.join(DATA_DIR, "bio.json"); +const CLICKS = path.join(DATA_DIR, "bio-clicks.json"); +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, null, 1)); fs.renameSync(tmp, f); }; +const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); +const RESERVED = new Set(["api", "go", "admin", "login", "quorla", "static", "assets"]); +const HEX = /^#[0-9a-f]{6}$/i; + +export function get(slug) { + clients.safeSlug(slug); + const c = readJson(FILE, {})[slug]; + return { enabled: false, path: slug, title: "", bio: "", links: [], showPosts: true, utm: true, theme: { bg: "#ffffff", fg: "#1f2024", button: "#242142", buttonText: "#ffffff" }, ...(c || {}) }; +} + +export function save(slug, patch) { + clients.safeSlug(slug); + const db = readJson(FILE, {}); + const cur = get(slug); + const p = patch.path !== undefined ? String(patch.path).toLowerCase().trim() : cur.path; + if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(p) || RESERVED.has(p)) throw err("Page address: 2–40 lowercase letters, numbers or dashes."); + if (Object.entries(db).some(([s, v]) => s !== slug && v.path === p)) throw err("Another client already uses that address."); + const links = (patch.links ?? cur.links).slice(0, 20).map((l) => { + const url = String(l.url || "").trim(); + if (!/^(https?:\/\/|mailto:|tel:)/i.test(url)) throw err(`“${l.title || url}”: links must start with https://, mailto: or tel:.`); + return { id: l.id || crypto.randomUUID().slice(0, 6), title: String(l.title || "").trim().slice(0, 80) || url, url: url.slice(0, 1000) }; + }); + const theme = { ...cur.theme }; + for (const k of ["bg", "fg", "button", "buttonText"]) if (patch.theme?.[k] !== undefined) { if (!HEX.test(patch.theme[k])) throw err("Colours must be like #242142."); theme[k] = patch.theme[k]; } + db[slug] = { + enabled: patch.enabled !== undefined ? Boolean(patch.enabled) : cur.enabled, path: p, + title: patch.title !== undefined ? String(patch.title).slice(0, 80) : cur.title, bio: patch.bio !== undefined ? String(patch.bio).slice(0, 300) : cur.bio, + links, showPosts: patch.showPosts !== undefined ? Boolean(patch.showPosts) : cur.showPosts, utm: patch.utm !== undefined ? Boolean(patch.utm) : cur.utm, theme, + }; + writeJson(FILE, db); + return get(slug); +} + +export function byPath(p) { + const hit = Object.entries(readJson(FILE, {})).find(([, v]) => v.path === String(p || "").toLowerCase() && v.enabled); + return hit ? { slug: hit[0], ...get(hit[0]) } : null; +} + +function withUtm(url, slug) { + if (!/^https?:/i.test(url)) return url; + try { + const u = new URL(url); + if (!u.searchParams.has("utm_source")) { u.searchParams.set("utm_source", "instagram"); u.searchParams.set("utm_medium", "social"); u.searchParams.set("utm_campaign", "link_in_bio"); u.searchParams.set("utm_content", slug); } + return u.toString(); + } catch { return url; } +} + +/** Where a click should go (counted). Null if unknown. */ +export function click(p, linkId) { + const page = byPath(p); + const link = page?.links.find((l) => l.id === linkId); + if (!link) return null; + const db = readJson(CLICKS, {}); + const day = new Date().toISOString().slice(0, 10); + const c = ((db[page.slug] ||= {})[linkId] ||= {}); + c[day] = (c[day] || 0) + 1; + // Keep 120 days. + const cutoff = new Date(Date.now() - 120 * 86400000).toISOString().slice(0, 10); + for (const k of Object.keys(c)) if (k < cutoff) delete c[k]; + writeJson(CLICKS, db); + return page.utm ? withUtm(link.url, page.slug) : link.url; +} + +/** Clicks per link over the last N days. */ +export function stats(slug, days = 30) { + const db = readJson(CLICKS, {})[slug] || {}; + const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); + const page = get(slug); + const per = page.links.map((l) => ({ id: l.id, title: l.title, clicks: Object.entries(db[l.id] || {}).filter(([d]) => d >= cutoff).reduce((s, [, n]) => s + n, 0) })); + return { days, total: per.reduce((s, x) => s + x.clicks, 0), links: per }; +} + +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); + +/** The public page as HTML (no JS needed). */ +export function render(page) { + const snap = metrics.readSnapshot(page.slug); + const prof = snap?.ig?.profile || {}; + const name = page.title || prof.name || clients.getClient(page.slug)?.name || ""; + const bio = page.bio || prof.biography || ""; + const avatar = prof.profile_picture_url || null; + const posts = page.showPosts ? (snap?.ig?.posts || []).filter((p) => p.permalink && (p.thumbnail_url || p.media_url)).slice(0, 6) : []; + const t = page.theme; + return ` + +${esc(name)} +${avatar ? `` : ""} +
+${avatar ? `` : ""} +

${esc(name)}

${bio ? `

${esc(bio)}

` : ""} +${page.links.map((l) => `${esc(l.title)}`).join("\n")} +${posts.length ? `
${posts.map((p) => ``).join("")}
` : ""} +
`; +} diff --git a/dashboard/server/clients.js b/dashboard/server/clients.js index b37919c..3911c68 100644 --- a/dashboard/server/clients.js +++ b/dashboard/server/clients.js @@ -65,7 +65,7 @@ export function listDocs(slug) { for (const [kind, sub] of Object.entries(DOC_DIRS)) { const d = path.join(dir, sub); if (!fs.existsSync(d)) continue; - for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? "report" : "calendar"); + for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? (/-monthly\.md$/.test(f) ? "monthly" : "report") : "calendar"); } for (const f of ["brand.md", "goals.md", "constraints.md", "history.md"]) if (fs.existsSync(path.join(dir, f))) add(f, "setup"); return docs.sort((a, b) => (a.kind === "setup") - (b.kind === "setup") || b.updatedAt.localeCompare(a.updatedAt)); diff --git a/dashboard/server/config.js b/dashboard/server/config.js index 20211c2..672cdf7 100644 --- a/dashboard/server/config.js +++ b/dashboard/server/config.js @@ -10,7 +10,7 @@ loadEnv(); export { PROJECT_ROOT }; export const CLIENTS_DIR = path.join(PROJECT_ROOT, "clients"); export const TEMPLATE_DIR = path.join(CLIENTS_DIR, "_template"); -export const DATA_DIR = path.join(PROJECT_ROOT, "data", "dashboard"); +export const DATA_DIR = process.env.QUORLA_DASHBOARD_DATA || path.join(PROJECT_ROOT, "data", "dashboard"); // override for tests export const BRANDING_DIR = path.join(PROJECT_ROOT, "branding"); export const WEB_DIST = path.join(PROJECT_ROOT, "dashboard", "web", "dist"); diff --git a/dashboard/server/control.js b/dashboard/server/control.js new file mode 100644 index 0000000..195f7ef --- /dev/null +++ b/dashboard/server/control.js @@ -0,0 +1,207 @@ +// Control centre for hosted Quorla (only when QUORLA_CONTROL=true, i.e. on app.quorlasocial.com). +// One Quorla per agency: its own container, data and address. This module keeps the list of agencies ("tenants"), +// takes sign-ups through Stripe Checkout, follows the subscription through Stripe webhooks, and tells the +// provisioning script on the server what each agency's container should be (running / stopped / removed). +// The web app never runs Docker itself; deploy/provision-tenants.sh does, using PROVISION_TOKEN. +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { PROJECT_ROOT } from "../../connector/src/env.js"; +import * as notify from "./notify.js"; + +const DIR = process.env.QUORLA_CONTROL_DIR || path.join(PROJECT_ROOT, "data", "control"); // override for tests +const FILE = path.join(DIR, "tenants.json"); +fs.mkdirSync(DIR, { recursive: true }); +const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } }; +const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE); }; +const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); +const now = () => new Date().toISOString(); + +export const enabled = () => process.env.QUORLA_CONTROL === "true"; +const BASE_DOMAIN = () => process.env.TENANT_BASE_DOMAIN || "quorlasocial.com"; +const RESERVED = new Set(["app", "www", "api", "mail", "webmail", "admin", "status", "help", "docs", "blog", "billing", "control", "quorla", "test", "staging"]); +const PUBLIC = (t) => { const { setupToken, ...rest } = t; return { ...rest, setupPending: Boolean(setupToken) }; }; + +export function list() { return Object.values(read()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map(PUBLIC); } + +// ---------- Stripe (REST, no SDK) ---------- +async function stripe(pathname, form) { + const key = process.env.STRIPE_SECRET_KEY; + if (!key) throw err("Stripe isn't set up (STRIPE_SECRET_KEY).", 503); + const body = new URLSearchParams(); + const add = (k, v) => { if (v === undefined || v === null) return; if (typeof v === "object") for (const [kk, vv] of Object.entries(v)) add(`${k}[${kk}]`, vv); else body.append(k, String(v)); }; + for (const [k, v] of Object.entries(form || {})) add(k, v); + const r = await fetch(`https://api.stripe.com/v1/${pathname}`, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/x-www-form-urlencoded" }, body }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw err(`Stripe: ${j.error?.message || r.status}`, 502); + return j; +} + +/** Verify a Stripe webhook: Stripe-Signature "t=…,v1=…" over `${t}.${rawBody}`, within 5 minutes. */ +export function verifyWebhook(rawBody, header) { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret) throw err("Webhook secret missing.", 503); + const parts = Object.fromEntries(String(header || "").split(",").map((p) => p.split("=")).filter((p) => p.length === 2).map(([k, v]) => [k.trim(), v.trim()])); + const sigs = String(header || "").split(",").filter((p) => p.trim().startsWith("v1=")).map((p) => p.trim().slice(3)); + const t = Number(parts.t); + if (!t || !sigs.length) throw err("Bad signature header.", 400); + if (Math.abs(Date.now() / 1000 - t) > 300) throw err("Webhook too old.", 400); + const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); + const ok = sigs.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))); + if (!ok) throw err("Signature doesn't match.", 400); + return JSON.parse(rawBody); +} + +// ---------- sign-up ---------- +function validate({ agencyName, ownerName, ownerEmail, slug }) { + const s = String(slug || "").toLowerCase().trim(); + if (!String(agencyName || "").trim()) throw err("Enter your agency's name."); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(ownerEmail || "").trim())) throw err("Enter a valid work email."); + if (!/^[a-z][a-z0-9-]{2,30}$/.test(s) || RESERVED.has(s)) throw err("Workspace address: 3–31 lowercase letters, numbers or dashes, starting with a letter."); + const db = read(); + if (db[s] && !["pending_payment", "cancelled"].includes(db[s].status)) throw err("That workspace address is taken. Try another."); + return { slug: s, agencyName: String(agencyName).trim().slice(0, 60), ownerName: String(ownerName || "").trim().slice(0, 60), ownerEmail: String(ownerEmail).trim().toLowerCase().slice(0, 120) }; +} + +/** Public sign-up: creates the tenant record and returns where to send the person (Stripe Checkout or a thank-you). */ +export async function signup(input, origin) { + const t = validate(input); + const db = read(); + db[t.slug] = { + ...t, plan: "hosted", status: "pending_payment", desired: "none", actual: "none", port: null, + url: `https://${t.slug}.${BASE_DOMAIN()}`, setupToken: crypto.randomBytes(18).toString("base64url"), + createdAt: now(), updatedAt: now(), stripe: {}, history: [{ at: now(), event: "signed up" }], + }; + write(db); + if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_PRICE_ID) { + // No Stripe yet: an admin approves the agency by hand on the Agencies page. + await notify.notify({ type: "tenant_signup", title: `New agency sign-up: ${t.agencyName}`, body: `${t.ownerName} <${t.ownerEmail}> · ${t.slug}`, link: "/admin/agencies", to: "admins" }); + return { next: "manual" }; + } + const trial = Number(process.env.STRIPE_TRIAL_DAYS || 0); + const session = await stripe("checkout/sessions", { + mode: "subscription", customer_email: t.ownerEmail, client_reference_id: t.slug, + "line_items[0][price]": process.env.STRIPE_PRICE_ID, "line_items[0][quantity]": 1, + success_url: `${origin}/signup/done?w=${t.slug}`, cancel_url: `${origin}/signup?cancelled=1`, + metadata: { slug: t.slug }, subscription_data: { metadata: { slug: t.slug }, ...(trial > 0 ? { trial_period_days: trial } : {}) }, + allow_promotion_codes: "true", + }); + return { next: "stripe", url: session.url }; +} + +// ---------- subscription lifecycle ---------- +function update(slug, fn, event) { + const db = read(); + const t = db[slug]; + if (!t) return null; + fn(t); + t.updatedAt = now(); + if (event) (t.history ||= []).push({ at: now(), event }); + t.history = t.history.slice(-100); + write(db); + return t; +} +const slugFromObj = (o) => o?.metadata?.slug || o?.client_reference_id || o?.subscription_details?.metadata?.slug || null; +const bySubscription = (id) => Object.values(read()).find((t) => t.stripe?.subscription === id)?.slug || null; + +/** Apply one Stripe event. Returns a short description for the log. */ +export async function onStripeEvent(evt) { + const o = evt.data?.object || {}; + switch (evt.type) { + case "checkout.session.completed": { + const slug = slugFromObj(o); + if (!slug) return "no slug"; + update(slug, (t) => { t.status = "provisioning"; t.desired = "running"; t.stripe = { customer: o.customer, subscription: o.subscription }; }, "paid; workspace queued"); + await notify.notify({ type: "tenant_paid", level: "ok", title: `New paying agency: ${read()[slug]?.agencyName}`, link: "/admin/agencies", to: "admins", email: true }); + return `provisioning ${slug}`; + } + case "customer.subscription.updated": { + const slug = slugFromObj(o) || bySubscription(o.id); + if (!slug) return "unknown subscription"; + update(slug, (t) => { + if (["active", "trialing"].includes(o.status)) { if (t.status === "suspended" && t.suspendedFor === "billing") { t.desired = "running"; delete t.suspendedFor; } if (t.actual === "running") t.status = "active"; } + else if (o.status === "past_due") t.status = "past_due"; + else if (["unpaid", "canceled", "incomplete_expired"].includes(o.status)) { t.status = "suspended"; t.suspendedFor = "billing"; t.desired = "stopped"; } + }, `subscription ${o.status}`); + return `${slug} ${o.status}`; + } + case "customer.subscription.deleted": { + const slug = slugFromObj(o) || bySubscription(o.id); + if (!slug) return "unknown subscription"; + update(slug, (t) => { t.status = "cancelled"; t.desired = "stopped"; }, "subscription cancelled; workspace stopped (data kept)"); + return `${slug} cancelled`; + } + case "invoice.payment_failed": { + const slug = bySubscription(o.subscription); + if (slug) update(slug, (t) => { if (t.status === "active") t.status = "past_due"; }, "payment failed"); + return `payment failed ${slug || ""}`; + } + default: return `ignored ${evt.type}`; + } +} + +// ---------- admin actions ---------- +export function approve(slug) { return PUBLIC(update(slug, (t) => { t.status = "provisioning"; t.desired = "running"; }, "approved by admin") || (() => { throw err("Unknown agency.", 404); })()); } +export function suspend(slug) { return PUBLIC(update(slug, (t) => { t.status = "suspended"; t.suspendedFor = "admin"; t.desired = "stopped"; }, "suspended by admin") || (() => { throw err("Unknown agency.", 404); })()); } +export function resume(slug) { return PUBLIC(update(slug, (t) => { t.desired = "running"; delete t.suspendedFor; t.status = t.actual === "running" ? "active" : "provisioning"; }, "resumed by admin") || (() => { throw err("Unknown agency.", 404); })()); } +/** Remove the container. Data stays on disk (the script archives it) until someone deletes it by hand. */ +export function remove(slug) { return PUBLIC(update(slug, (t) => { t.status = "removed"; t.desired = "removed"; }, "removal requested by admin") || (() => { throw err("Unknown agency.", 404); })()); } + +export async function billingPortal(slug, returnUrl) { + const t = read()[slug]; + if (!t?.stripe?.customer) throw err("This agency has no Stripe customer yet."); + return (await stripe("billing_portal/sessions", { customer: t.stripe.customer, return_url: returnUrl })).url; +} + +// ---------- provisioning script API ---------- +export function checkProvisionToken(header) { + const need = process.env.PROVISION_TOKEN || ""; + const got = String(header || "").replace(/^Bearer\s+/i, ""); + if (!need || got.length !== need.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(need))) throw err("Not allowed.", 401); +} + +/** What the script should do: every tenant whose container isn't in its desired state. Assigns ports. */ +export function tasks() { + const db = read(); + const used = new Set(Object.values(db).map((t) => t.port).filter(Boolean)); + let changed = false; + const out = []; + for (const t of Object.values(db)) { + if (t.desired === "none" || t.desired === t.actual) continue; + if (t.desired === "running" && !t.port) { let p = 4400; while (used.has(p)) p++; t.port = p; used.add(p); changed = true; } + out.push({ slug: t.slug, desired: t.desired, port: t.port, agencyName: t.agencyName, url: t.url, setupToken: t.setupToken || null }); + } + if (changed) write(db); + return out; +} + +/** The script reports a container's state. First time a workspace runs: email the owner their setup link. */ +export async function report(slug, { actual, error }) { + const before = read()[slug]; + if (!before) throw err("Unknown agency.", 404); + const t = update(slug, (x) => { + if (error) { x.lastError = String(error).slice(0, 500); return; } + x.actual = actual; delete x.lastError; + if (actual === "running" && ["provisioning", "past_due"].includes(x.status)) x.status = "active"; + }, error ? `provisioning error: ${String(error).slice(0, 120)}` : `container ${actual}`); + if (!error && actual === "running" && before.actual !== "running" && t.setupToken && !t.welcomedAt) { + const sent = await notify.sendEmail({ + to: t.ownerEmail, subject: `Your Quorla workspace is ready`, heading: `Welcome to Quorla, ${t.ownerName || t.agencyName}`, + lines: [`Your workspace for ${t.agencyName} is ready at ${t.url}.`, "Use the button to create your admin account. The link works once; after that, sign in with your email and password.", "Then follow the Getting started list: connect your clients' accounts, invite your team and schedule your first post."], + button: { label: "Create my account", url: `${t.url}/?setup=${t.setupToken}` }, fromName: "Quorla", + }); + update(slug, (x) => { if (sent.sent) x.welcomedAt = now(); }, sent.sent ? "welcome email sent" : `welcome email not sent: ${sent.reason}`); + } + return PUBLIC(read()[slug]); +} + +/** Send the welcome email again (the setup link only works until the first account exists). */ +export async function resendWelcome(slug) { + const t = read()[slug]; + if (!t) throw err("Unknown agency.", 404); + if (t.actual !== "running") throw err("The workspace isn't running yet."); + update(slug, (x) => { delete x.welcomedAt; }); + await report(slug, { actual: "running" }); + update(slug, () => {}, "welcome email re-sent"); + return PUBLIC(read()[slug]); +} diff --git a/dashboard/server/control/done.html b/dashboard/server/control/done.html new file mode 100644 index 0000000..dd3bfa1 --- /dev/null +++ b/dashboard/server/control/done.html @@ -0,0 +1,34 @@ + + + + + +Your Quorla workspace is on its way + + + + +
+ Quorla +
+

Thanks, you're in

+ + +

Nothing in your inbox after 15 minutes? Check spam, or write to support@quorlasocial.com.

+
+
+ + + diff --git a/dashboard/server/control/signup.html b/dashboard/server/control/signup.html new file mode 100644 index 0000000..056da09 --- /dev/null +++ b/dashboard/server/control/signup.html @@ -0,0 +1,72 @@ + + + + + +Start your Quorla workspace + + + + + +
+ +

Start your Quorla workspace

+

Your agency gets its own private workspace: your clients, your team, your data. Set up takes a few minutes.

+ +
+
+
+
We send your setup link here.
+
+ +
.quorlasocial.com
+
Lowercase letters, numbers and dashes.
+
+ + + +
+

By continuing you agree to the Terms and Privacy Policy. Questions? support@quorlasocial.com

+
+ + + diff --git a/dashboard/server/docs.js b/dashboard/server/docs.js index b433b96..60c5695 100644 --- a/dashboard/server/docs.js +++ b/dashboard/server/docs.js @@ -4,6 +4,7 @@ import path from "node:path"; import { marked } from "marked"; import { safeSlug } from "./clients.js"; import { toPdf } from "../../tools/report-pdf/build.mjs"; +import { reportOptions } from "./workspace.js"; /** Resolve a document path inside one client's folder (blocks ../ escapes). */ export function resolveDoc(slug, rel) { @@ -42,6 +43,6 @@ export function renderDoc(slug, rel) { export function exportPdf(slug, rel) { const full = resolveDoc(slug, rel); if (!full.endsWith(".md")) throw Object.assign(new Error("Pick a markdown document."), { status: 400 }); - const written = toPdf(full); + const written = toPdf(full, reportOptions()); return path.relative(safeSlug(slug), written).replace(/\\/g, "/"); } diff --git a/dashboard/server/health.js b/dashboard/server/health.js new file mode 100644 index 0000000..d40d79c --- /dev/null +++ b/dashboard/server/health.js @@ -0,0 +1,70 @@ +// System health for admins: is everything that runs in the background actually running? +import fs from "node:fs"; +import path from "node:path"; +import { PROJECT_ROOT } from "../../connector/src/env.js"; +import * as clients from "./clients.js"; +import * as metrics from "./metrics.js"; +import * as jobs from "./jobs.js"; +import * as notify from "./notify.js"; +import * as sched from "../../connector/src/schedule.js"; +import * as extra from "../../connector/src/platforms/index.js"; + +const HOUR = 3600000; +function dirSize(dir) { + let total = 0; + const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else { try { total += fs.statSync(p).size; } catch { /* gone */ } } } }; + try { walk(dir); } catch { return null; } + return total; +} + +/** A list of checks: { area, status: ok|warn|bad, title, detail }. */ +export function checks() { + const out = []; + const add = (area, status, title, detail = "") => out.push({ area, status, title, detail }); + + // Publishing + const pubOn = process.env.ALLOW_PUBLISH === "true", schedOn = process.env.SCHEDULER_ENABLED !== "false"; + add("Publishing", pubOn && schedOn ? "ok" : "warn", pubOn && schedOn ? "Publishing and the scheduler are on" : `Publishing ${pubOn ? "on" : "OFF (ALLOW_PUBLISH)"} · scheduler ${schedOn ? "on" : "OFF (SCHEDULER_ENABLED)"}`); + const posts = sched.listPosts({}); + const stuck = posts.filter((p) => p.status === "approved" && !p.retryAt && Date.now() - new Date(p.publishAt) > 15 * 60000); + add("Publishing", stuck.length ? "bad" : "ok", stuck.length ? `${stuck.length} approved post${stuck.length === 1 ? "" : "s"} past due and not sent` : "No overdue posts", stuck.slice(0, 5).map((p) => `${p.clientName || p.client} · ${p.localTime}`).join(" · ")); + const failed = posts.filter((p) => ["failed", "partial"].includes(p.status) && Date.now() - new Date(p.publishedAt || p.publishAt) < 7 * 24 * HOUR); + add("Publishing", failed.length ? "warn" : "ok", failed.length ? `${failed.length} failed or partly published in the last 7 days` : "No failed posts in the last 7 days"); + + // Numbers refresh + const all = clients.listClients().filter((c) => c.pageId || c.igId); + const stale = all.filter((c) => { const s = metrics.readSnapshot(c.slug); return !s || Date.now() - new Date(s.fetchedAt) > 36 * HOUR; }); + add("Numbers", stale.length ? "warn" : "ok", stale.length ? `${stale.length} of ${all.length} clients not refreshed in 36 hours` : `All ${all.length} linked clients refreshed in the last 36 hours`, stale.map((c) => c.name).join(", ")); + const snapErr = all.map((c) => [c, metrics.readSnapshot(c.slug)]).filter(([, s]) => s?.errors?.length); + if (snapErr.length) add("Numbers", "warn", `${snapErr.length} client${snapErr.length === 1 ? "" : "s"} had errors in the last refresh`, snapErr.map(([c, s]) => `${c.name}: ${s.errors[0]}`).join(" · ").slice(0, 400)); + + // Connections + const conns = extra.vault.listConnections({}); + const broken = conns.filter((c) => c.status !== "connected"); + add("Connections", broken.length ? "bad" : "ok", broken.length ? `${broken.length} connection${broken.length === 1 ? "" : "s"} need reconnecting` : conns.length ? `${conns.length} platform connection${conns.length === 1 ? "" : "s"} working` : "No platform connections yet (Connections page)", broken.map((c) => `${c.client} ${c.platform}: ${c.lastError || c.status}`).join(" · ").slice(0, 400)); + const soon = conns.filter((c) => c.refreshExpiresAt && new Date(c.refreshExpiresAt) - Date.now() < 7 * 24 * HOUR); + if (soon.length) add("Connections", "warn", `${soon.length} sign-in${soon.length === 1 ? "" : "s"} expire within 7 days`, soon.map((c) => `${c.client} ${c.platform} (${c.refreshExpiresAt.slice(0, 10)})`).join(" · ")); + + // Email + add("Email", notify.emailConfigured() ? "ok" : "warn", notify.emailConfigured() ? `Email set up (${process.env.SMTP_HOST})` : "Email not set up: notifications only appear in the bell", notify.emailConfigured() ? "" : "Add SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM to .env."); + + // AI runs + const recent = jobs.listJobs({}).filter((j) => Date.now() - new Date(j.createdAt) < 7 * 24 * HOUR); + const jobFails = recent.filter((j) => ["failed", "interrupted"].includes(j.status)); + add("AI runs", process.env.ANTHROPIC_API_KEY ? (jobFails.length ? "warn" : "ok") : "warn", + !process.env.ANTHROPIC_API_KEY ? "No Anthropic API key: AI runs (audits, weekly plans) can't start" : jobFails.length ? `${jobFails.length} AI run${jobFails.length === 1 ? "" : "s"} failed this week` : `AI runs working (${recent.length} this week)`, + jobFails.slice(0, 3).map((j) => `${j.type} ${j.slug || ""}: ${j.error || j.status}`).join(" · ")); + + // Backups and disk + const bdir = path.join(PROJECT_ROOT, "backups"); + let latest = null; + try { latest = fs.readdirSync(bdir).filter((f) => /^quorla-.*\.tar\.gz$/.test(f)).map((f) => ({ f, t: fs.statSync(path.join(bdir, f)).mtimeMs })).sort((a, b) => b.t - a.t)[0] || null; } catch { /* not mounted */ } + add("Backups", !latest ? "warn" : Date.now() - latest.t > 30 * HOUR ? "bad" : "ok", + !latest ? "Can't see any backups" : `Last backup ${Math.round((Date.now() - latest.t) / HOUR)} h ago`, + !latest ? "Run deploy/backup.sh daily with cron and mount ./backups in docker-compose (read-only)." : latest.f); + const size = dirSize(path.join(PROJECT_ROOT, "data")); + if (size != null) add("Storage", size > 20 * 1024 ** 3 ? "warn" : "ok", `Data folder uses ${(size / 1024 ** 3).toFixed(2)} GB`, "Uploaded media lives here; old published media can be archived."); + + const order = { bad: 0, warn: 1, ok: 2 }; + return { at: new Date().toISOString(), version: process.env.QUORLA_VERSION || null, checks: out.sort((a, b) => order[a.status] - order[b.status]) }; +} diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 5c79642..564f52e 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -8,12 +8,26 @@ import { login, logout, currentUser, requireAuth, requireAdmin, setupFirstAdmin, import * as users from "./users.js"; 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 monthly from "./monthly.js"; +import * as bio from "./bio.js"; +import * as activity from "./activity.js"; +import * as health from "./health.js"; +import * as onboarding from "./onboarding.js"; +import * as workspace from "./workspace.js"; +import * as control from "./control.js"; import * as odoo from "./odoo.js"; import * as jobs from "./jobs.js"; import * as docs from "./docs.js"; import * as meta from "../../connector/src/meta.js"; import * as sched from "../../connector/src/schedule.js"; import * as lib from "../../connector/src/library.js"; +import * as plan from "../../connector/src/plan.js"; +import * as competitors from "../../connector/src/competitors.js"; +import * as brandkit from "../../connector/src/brandkit.js"; import * as extra from "../../connector/src/platforms/index.js"; import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js"; import { DATA_DIR as _DD } from "./config.js"; @@ -22,6 +36,15 @@ const app = express(); app.disable("x-powered-by"); // Behind Caddy in Docker the proxy is on the private network: TRUST_PROXY=uniquelocal (see docker-compose.yml). app.set("trust proxy", process.env.TRUST_PROXY || "loopback"); +// Stripe webhook needs the raw body to verify the signature, so it comes before the JSON parser. +app.post("/control/stripe/webhook", express.raw({ type: "*/*", limit: "1mb" }), async (req, res) => { + if (!control.enabled()) return res.status(404).end(); + try { + const evt = control.verifyWebhook(req.body.toString("utf8"), req.headers["stripe-signature"]); + console.log("Stripe:", evt.type, await control.onStripeEvent(evt)); + res.json({ received: true }); + } catch (e) { console.error("Stripe webhook:", e.message); res.status(e.status || 400).json({ error: e.message }); } +}); app.use(express.json({ limit: "200kb" })); app.use((_req, res, next) => { res.setHeader("X-Content-Type-Options", "nosniff"); @@ -46,7 +69,9 @@ const h = (fn) => async (req, res) => { app.post("/api/auth/login", login); app.post("/api/auth/logout", logout); app.post("/api/auth/setup", setupFirstAdmin); -app.get("/api/auth/me", (req, res) => res.json({ user: currentUser(req), agency: AGENCY_NAME, setupNeeded: !users.hasUsers() })); +app.get("/api/auth/me", (req, res) => { const w = workspace.get(); res.json({ user: currentUser(req), agency: workspace.agencyName(), workspace: { accent: w.accent, hasLogo: w.hasLogo }, control: control.enabled(), setupNeeded: !users.hasUsers() }); }); +// The agency's own logo (white-label), public so the sign-in page and client pages can show it. +app.get("/brand/agency-logo", (_req, res) => { const f = workspace.logoPath(); if (!f) return res.status(404).end(); res.setHeader("Cache-Control", "no-cache"); res.sendFile(f); }); // App (Quorla) branding. Agency branding for PDF reports stays in branding/ and is used by tools/report-pdf. app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app.png"))); app.get("/brand/logo-white.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app-white.png"))); @@ -59,6 +84,56 @@ app.get("/privacy", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "privacy.ht app.get("/terms", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "terms.html"))); app.get("/legal/legal.css", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "legal.css"))); +// Client approval portal: a private link per client, no login. Read-only except approve / request changes. +const PORTAL_PAGE = fileURLToPath(new URL("./portal/index.html", import.meta.url)); +const portalClient = (req, res) => { const c = portal.clientForToken(req.params.token); if (!c) res.status(404).json({ error: "This link isn't active." }); return c; }; +const noIndex = (_req, res, next) => { res.setHeader("X-Robots-Tag", "noindex, nofollow"); res.setHeader("Referrer-Policy", "no-referrer"); next(); }; +app.get("/p/:token", noIndex, (req, res) => (portal.clientForToken(req.params.token) ? res.sendFile(PORTAL_PAGE) : res.status(404).send("This link isn't active. Ask your agency for a new one."))); +app.get("/p/:token/data", noIndex, (req, res) => { const c = portalClient(req, res); if (c) res.json(portal.view(c)); }); +app.get("/p/:token/media/:post/:i", noIndex, (req, res) => { + const c = portalClient(req, res); if (!c) return; + const f = portal.mediaFile(c, req.params.post, req.params.i); + if (!f) return res.status(404).end(); + res.setHeader("Cache-Control", "private, max-age=600"); + res.sendFile(f); +}); +app.post("/p/:token/decision/:post", noIndex, (req, res) => { + const c = portalClient(req, res); if (!c) return; + try { res.json({ post: sched.clientDecision(req.params.post, c, req.body || {}).id }); } + catch (e) { res.status(e.status || 400).json({ error: e.message }); } +}); + +// Hosted Quorla control centre (QUORLA_CONTROL=true): public sign-up and the provisioning script's API. +const CONTROL_DIR = fileURLToPath(new URL("./control/", import.meta.url)); +const signupHits = new Map(); +app.get("/signup", (req, res) => (control.enabled() ? res.sendFile(path.join(CONTROL_DIR, "signup.html")) : res.status(404).send("Not found"))); +app.get("/signup/done", (req, res) => (control.enabled() ? res.sendFile(path.join(CONTROL_DIR, "done.html")) : res.status(404).send("Not found"))); +app.post("/control/signup", async (req, res) => { + if (!control.enabled()) return res.status(404).end(); + const ip = req.ip, hour = Date.now() - 3600000; + const hits = (signupHits.get(ip) || []).filter((t) => t > hour); + if (hits.length >= 10) return res.status(429).json({ error: "Too many sign-ups from here. Try again later or email support@quorlasocial.com." }); + signupHits.set(ip, [...hits, Date.now()]); + if (String(req.body?.website || "")) return res.json({ next: "manual" }); // honeypot + try { res.json(await control.signup(req.body || {}, `${req.protocol}://${req.get("host")}`)); } catch (e) { res.status(e.status || 400).json({ error: e.message }); } +}); +app.get("/control/provision/tasks", (req, res) => { try { control.checkProvisionToken(req.headers.authorization); res.json({ tasks: control.tasks() }); } catch (e) { res.status(e.status || 400).json({ error: e.message }); } }); +app.post("/control/provision/:slug/report", async (req, res) => { try { control.checkProvisionToken(req.headers.authorization); res.json(await control.report(req.params.slug, req.body || {})); } catch (e) { res.status(e.status || 400).json({ error: e.message }); } }); + +// Link-in-bio pages (public): /l/ and counted redirects /l//go/. +app.get("/l/:path", (req, res) => { + const page = bio.byPath(req.params.path); + if (!page) return res.status(404).send("Not found"); + res.setHeader("Cache-Control", "public, max-age=300"); + res.type("html").send(bio.render(page)); +}); +app.get("/l/:path/go/:id", (req, res) => { + const to = bio.click(req.params.path, req.params.id); + if (!to) return res.status(404).send("Not found"); + res.setHeader("Cache-Control", "no-store"); + res.redirect(302, to); +}); + // Signed, expiring media links that platforms (TikTok, Threads, Google Business Profile) fetch posts from. app.get("/m/:token/:name", (req, res) => { const f = resolveMediaToken(req.params.token); @@ -86,6 +161,7 @@ app.get("/oauth/:platform/callback", async (req, res) => { // ---------- everything below needs a session ---------- app.use("/api", requireAuth); +app.use("/api", activity.middleware); // ---------- your account (everyone) ---------- app.patch("/api/me", h(async (req) => ({ user: users.renameSelf(req.user.id, req.body?.name) }))); @@ -268,7 +344,11 @@ const clientAccount = async (slug) => { const needPublishing = () => { if (process.env.ALLOW_PUBLISH !== "true") throw Object.assign(new Error("Publishing is switched off. Set ALLOW_PUBLISH=true in .env and restart."), { status: 403 }); }; app.get("/api/schedule", h(async (req) => ({ posts: sched.listPosts({ client: req.query.client || undefined }), publishing: process.env.ALLOW_PUBLISH === "true" }))); -app.get("/api/schedule/suggest", h(async (req) => { const c = sched.clientInfo(clients.safeSlug(req.query.client) && req.query.client); return { timezone: c.timezone, times: sched.suggestedTimes(c.timezone) }; })); +app.get("/api/schedule/suggest", h(async (req) => { + const c = sched.clientInfo(clients.safeSlug(req.query.client) && req.query.client); + const best = published.bestTimes(c.slug, c.timezone); + return { timezone: c.timezone, times: best.times.length ? best.times : sched.suggestedTimes(c.timezone), best }; +})); app.post("/api/schedule/upload", express.raw({ type: "*/*", limit: "500mb" }), h(async (req) => { const name = String(req.headers["x-filename"] || "upload").replace(/[^\w.\-]+/g, "_").slice(-80); if (!/\.(jpe?g|png|mp4|mov|m4v)$/i.test(name)) throw Object.assign(new Error("Upload a JPG, PNG, MP4 or MOV file."), { status: 400 }); @@ -280,6 +360,15 @@ app.post("/api/schedule", h(async (req) => ({ post: sched.createPost({ ...req.bo app.patch("/api/schedule/:id", h(async (req) => ({ post: sched.updatePost(req.params.id, req.body || {}, req.user.name || req.user.email) }))); app.post("/api/schedule/:id/approve", requireAdmin, h(async (req) => { needPublishing(); return { post: await sched.approvePost(req.params.id, req.user.name || req.user.email) }; })); app.post("/api/schedule/:id/retry", requireAdmin, h(async (req) => { needPublishing(); return { post: sched.retryPost(req.params.id, req.user.name || req.user.email) }; })); +app.post("/api/schedule/:id/send-to-client", h(async (req) => { + const p = sched.getPost(req.params.id); + if (!portal.linkFor(p.client).enabled) throw Object.assign(new Error("Create this client's approval link first (client page → Client approval)."), { status: 400 }); + return { post: sched.requestClientReview(req.params.id, who(req)) }; +})); +app.get("/api/portal/:client", h(async (req) => portal.linkFor(req.params.client))); +app.post("/api/portal/:client", requireAdmin, h(async (req) => portal.issue(req.params.client, who(req), req.body || {}))); +app.patch("/api/portal/:client", requireAdmin, h(async (req) => portal.updateContact(req.params.client, req.body || {}))); +app.delete("/api/portal/:client", requireAdmin, h(async (req) => { portal.revoke(req.params.client); return { enabled: false }; })); app.post("/api/schedule/:id/cancel", h(async (req) => ({ post: await sched.cancelPost(req.params.id, req.user.name || req.user.email) }))); app.get("/api/schedule/media", h(async (req, res) => { const f = path.resolve(String(req.query.path || "")); @@ -288,6 +377,103 @@ app.get("/api/schedule/media", h(async (req, res) => { })); +// ---------- monthly client report ---------- +app.get("/api/clients/:slug/monthly", h(async (req) => ({ settings: monthly.settingsFor(req.params.slug), lastMonth: monthly.lastMonth(), email: notify.emailConfigured() }))); +app.put("/api/clients/:slug/monthly", requireAdmin, h(async (req) => ({ settings: monthly.setSettings(req.params.slug, req.body || {}) }))); +app.post("/api/clients/:slug/monthly/build", h(async (req) => monthly.buildWithPdf(req.params.slug, String(req.body?.month || monthly.lastMonth())))); +app.post("/api/clients/:slug/monthly/email", requireAdmin, h(async (req) => monthly.emailReport(req.params.slug, String(req.body?.month || monthly.lastMonth())))); + +// ---------- brand kit: hashtag sets, phrases, words to avoid; caption checks ---------- +app.get("/api/brandkit/:client", h(async (req) => brandkit.get(req.params.client))); +app.put("/api/brandkit/:client", h(async (req) => brandkit.save(req.params.client, req.body || {}, who(req)))); +app.post("/api/brandkit/:client/check", h(async (req) => ({ warnings: brandkit.checkCaption(req.params.client, req.body?.caption, { platforms: req.body?.platforms || [] }) }))); + +// ---------- link in bio ---------- +app.get("/api/bio/:client", h(async (req) => ({ page: bio.get(req.params.client), stats: bio.stats(req.params.client, Number(req.query.days) || 30) }))); +app.put("/api/bio/:client", requireAdmin, h(async (req) => ({ page: bio.save(req.params.client, req.body || {}), stats: bio.stats(req.params.client) }))); + +// ---------- Meta connection from Settings (hosted workspaces can't edit .env) ---------- +app.get("/api/meta/connection", requireAdmin, h(async () => ({ source: meta.metaSource() }))); +app.put("/api/meta/connection", requireAdmin, h(async (req) => ({ ...(await meta.saveSystemToken(req.body?.token, who(req))), source: meta.metaSource() }))); + +// ---------- workspace (white-label) ---------- +app.get("/api/workspace", h(async () => workspace.get())); +app.put("/api/workspace", requireAdmin, h(async (req) => workspace.save(req.body || {}, who(req)))); +app.post("/api/workspace/logo", requireAdmin, express.raw({ type: "*/*", limit: "3mb" }), h(async (req) => workspace.saveLogo(req.body, req.headers["x-filename"]))); +app.delete("/api/workspace/logo", requireAdmin, h(async () => { workspace.removeLogo(); return workspace.get(); })); + +// ---------- hosted agencies (control centre) ---------- +const needControl = (_req, res, next) => (control.enabled() ? next() : res.status(404).json({ error: "Not available." })); +app.get("/api/control/tenants", requireAdmin, needControl, h(async () => ({ tenants: control.list(), stripe: Boolean(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PRICE_ID), provisioner: Boolean(process.env.PROVISION_TOKEN) }))); +for (const action of ["approve", "suspend", "resume", "remove"]) app.post(`/api/control/tenants/:slug/${action}`, requireAdmin, needControl, h(async (req) => ({ tenant: control[action](req.params.slug) }))); +app.post("/api/control/tenants/:slug/welcome", requireAdmin, needControl, h(async (req) => ({ tenant: await control.resendWelcome(req.params.slug) }))); +app.post("/api/control/tenants/:slug/portal", requireAdmin, needControl, h(async (req) => ({ url: await control.billingPortal(req.params.slug, `${req.protocol}://${req.get("host")}/admin/agencies`) }))); + +// ---------- getting started checklist ---------- +app.get("/api/onboarding", requireAdmin, h(async () => onboarding.steps())); +app.post("/api/onboarding/dismiss", requireAdmin, h(async (req) => { onboarding.dismiss(req.body?.dismissed !== false); return onboarding.steps(); })); + +// ---------- admin: audit log and system health ---------- +app.get("/api/admin/audit", requireAdmin, h(async (req) => ({ items: activity.list({ days: Number(req.query.days) || 30, user: req.query.user || undefined, client: req.query.client || undefined, q: req.query.q || undefined }) }))); +app.get("/api/admin/health", requireAdmin, h(async () => health.checks())); + +// ---------- 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)) }))); +// Tell the person a plan item is assigned to (bell + email if they want it). +async function notifyAssignee(item, before = null) { + if (!item.assignee || (before && before.assignee === item.assignee) || !["idea", "brief", "production"].includes(item.status)) return; + const c = clients.getClient(item.client); + await notify.notify({ + type: "assigned", title: `New for you: ${c?.name || item.client} “${item.title}”`, to: [item.assignee], + body: `Due ${item.date}${item.format ? ` · ${item.format.toLowerCase()}` : ""}${item.brief ? ` · ${item.brief.slice(0, 200)}` : ""}`, + link: `/calendar?client=${item.client}&date=${item.date}`, client: item.client, + }); +} +app.post("/api/plan", h(async (req) => { const item = plan.createItem(req.body || {}, who(req)); await notifyAssignee(item); return { item }; })); +app.patch("/api/plan/:id", h(async (req) => { + const before = plan.listItems({}).find((i) => i.id === req.params.id); + const item = plan.updateItem(req.params.id, req.body || {}, who(req)); + await notifyAssignee(item, before); + return { item }; +})); +app.get("/api/team/names", h(async () => ({ names: users.listUsers().filter((u) => u.active).map((u) => u.name || u.email) }))); +app.post("/api/plan/:id/odoo-task", requireAdmin, h(async (req) => { + const item = plan.listItems({}).find((i) => i.id === req.params.id); + if (!item) throw Object.assign(new Error("Plan item not found."), { status: 404 }); + const c = clients.getClient(item.client); + const userIds = (req.body?.userId ? [req.body.userId] : []); + const description = [item.brief && `Brief: ${item.brief}`, item.caption && `Draft caption: ${item.caption}`, item.platforms?.length && `Platforms: ${item.platforms.join(", ")}`, `Upload the finished work in Quorla → Content library and pick this calendar item.`].filter(Boolean).join("\n\n"); + const task = await odoo.createTask({ projectId: req.body?.projectId, name: `${c?.name || item.client}: ${item.title}`, description, userIds, deadline: item.date }); + return { item: plan.updateItem(item.id, { taskRef: String(task.id), status: item.status === "idea" ? "brief" : item.status }, who(req)), task }; +})); +app.delete("/api/plan/:id", h(async (req) => { plan.removeItem(req.params.id); return { ok: true }; })); + +// ---------- competitors per client (Instagram) ---------- +function ownForCompare(slug) { + const snap = metrics.readSnapshot(slug); + if (!snap?.ig) return null; + return { + username: snap.ig.profile?.username, followers: snap.ig.profile?.followers_count ?? null, posts: snap.ig.posts || [], + history: metrics.readHistory(slug).map((h) => ({ date: h.date, followers: h.igFollowers })), + }; +} +app.get("/api/competitors", h(async (req) => { const c = String(req.query.client || ""); clients.safeSlug(c); return competitors.compare(c, ownForCompare(c)); })); +app.post("/api/competitors", h(async (req) => ({ added: await competitors.add(String(req.body?.client || ""), req.body?.username, { by: who(req), note: req.body?.note || "" }) }))); +app.delete("/api/competitors/:client/:username", h(async (req) => { competitors.remove(req.params.client, req.params.username); return { ok: true }; })); +app.post("/api/competitors/refresh", h(async (req) => competitors.refresh(String(req.body?.client || "") || undefined))); + +// ---------- published posts and their numbers ---------- +app.get("/api/published", h(async (req) => published.feed({ client: req.query.client ? (clients.safeSlug(req.query.client), String(req.query.client)) : undefined, platform: req.query.platform ? String(req.query.platform) : undefined, days: Number(req.query.days) || 30 }))); +app.post("/api/published/refresh", h(async () => published.refreshStats())); + // ---------- content library: team uploads finished work per client ---------- const who = (req) => req.user.name || req.user.email; const esc = (s) => String(s || "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]); @@ -298,7 +484,10 @@ app.post("/api/library/upload", h(async (req) => { return { path: file, size: fs.statSync(file).size }; })); app.post("/api/library", h(async (req) => { - const item = lib.createItem({ ...req.body, uploadedBy: who(req) }); + // Uploaded for a calendar item: inherit its date and Odoo task, and move the card to "in production". + const p = req.body?.planItem ? plan.listItems({ client: req.body.client }).find((i) => i.id === req.body.planItem) : null; + const item = lib.createItem({ ...req.body, forDate: req.body?.forDate || p?.date || "", taskRef: req.body?.taskRef || p?.taskRef || "", planItem: p?.id || "", uploadedBy: who(req) }); + if (p) plan.updateItem(p.id, { libraryItem: item.id, ...(["idea", "brief"].includes(p.status) ? { status: "production" } : {}) }, who(req)); const odooResult = await odoo.taskActivity(item.taskRef, { note: `

${esc(who(req))} uploaded ${esc(item.title)} (${item.format.toLowerCase()}, ${item.files.length} file${item.files.length > 1 ? "s" : ""}) to the Social Manager library for review.

`, stageMatch: "review" }); return { item, odoo: odooResult }; })); @@ -310,9 +499,36 @@ app.patch("/api/library/:id", h(async (req) => { app.post("/api/library/:id/review", requireAdmin, h(async (req) => { const item = lib.reviewItem(req.params.id, req.body || {}, who(req)); const ok = item.status === "approved"; + if (item.planItem) { try { plan.updateItem(item.planItem, { status: ok ? "ready" : "production" }, who(req)); } catch { /* plan item removed */ } } + if (!ok && item.uploadedBy) await notify.notify({ type: "library_changes", level: "warn", title: `Changes requested: ${item.title}`, body: item.reviewNote, to: [item.uploadedBy], link: `/library?client=${item.client}`, client: item.client }); await odoo.taskActivity(item.taskRef, { note: ok ? `

Approved by ${esc(who(req))}: ${esc(item.title)}.

` : `

Changes requested by ${esc(who(req))}: ${esc(item.reviewNote)}

`, stageMatch: ok ? "done" : "progress" }); return { item }; })); +// Schedule several approved items at once: one per slot (start date, every N days, cycling through the times). +// Items with their own planned date keep it. Creates drafts only; each still needs approval. +app.post("/api/library/bulk-schedule", h(async (req) => { + const { client, ids = [], startDate, everyDays = 1, times = [], platforms = [], options = {} } = req.body || {}; + clients.safeSlug(client); + if (!/^\d{4}-\d{2}-\d{2}$/.test(String(startDate))) throw Object.assign(new Error("Pick a start date."), { status: 400 }); + const slots = (times.length ? times : ["10:00"]).filter((t) => /^\d{2}:\d{2}$/.test(t)); + if (!slots.length) throw Object.assign(new Error("Times must be like 10:00."), { status: 400 }); + if (!platforms.length) throw Object.assign(new Error("Choose at least one platform."), { status: 400 }); + const step = Math.min(Math.max(Number(everyDays) || 1, 1), 14); + const items = ids.map((id) => lib.getItem(id)).filter((i) => i.client === client && i.status === "approved") + .sort((a, b) => (a.forDate || "9999").localeCompare(b.forDate || "9999") || a.createdAt.localeCompare(b.createdAt)); + const day = (iso, n) => { const d = new Date(`${iso}T12:00:00Z`); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); }; + const created = [], errors = []; + let k = 0; + for (const i of items) { + const own = i.forDate && i.forDate >= startDate; + const date = own ? i.forDate : day(startDate, k * step); + const time = slots[(own ? created.length : k) % slots.length]; + if (!own) k += 1; + try { const post = lib.scheduleItem(i.id, { platforms, localTime: `${date} ${time}`, options }, who(req)); created.push({ id: i.id, title: i.title, localTime: post.localTime, warnings: post.warnings }); } + catch (e) { errors.push({ id: i.id, title: i.title, error: e.message }); } + } + return { created, errors }; +})); app.post("/api/library/:id/schedule", h(async (req) => ({ post: lib.scheduleItem(req.params.id, req.body || {}, who(req)), item: lib.getItem(req.params.id) }))); app.post("/api/library/:id/archive", h(async (req) => ({ item: lib.archiveItem(req.params.id, who(req)) }))); app.get("/api/library/file", h(async (req, res) => { @@ -322,6 +538,9 @@ app.get("/api/library/file", h(async (req, res) => { })); // ---------- engagement: comment inbox ---------- +app.get("/api/inbox/reviews", h(async (req) => extra.gbpReviews(String(req.query.client || "")))); +app.post("/api/inbox/reviews/reply", h(async (req) => ({ reply: await extra.gbpReply(String(req.body?.client || ""), String(req.body?.reviewId || ""), req.body?.text) }))); +app.get("/api/inbox/youtube", h(async (req) => extra.youtubeComments(String(req.query.client || "")))); app.get("/api/inbox", h(async (req) => { const slug = req.query.client; clients.safeSlug(slug); return { comments: await meta.commentInbox(await clientAccount(slug), { days: Number(req.query.days || 14) }) }; @@ -369,10 +588,14 @@ function scheduleNightly() { if (next <= new Date()) next.setDate(next.getDate() + 1); setTimeout(async () => { try { console.log("Nightly refresh:", await metrics.refreshAll()); } catch (e) { console.error("Nightly refresh failed", e); } + try { console.log("Post stats:", await published.refreshStats()); } catch (e) { console.error("Post stats failed", e); } + try { console.log("Competitors:", await competitors.refresh()); } catch (e) { console.error("Competitors refresh failed", e); } scheduleNightly(); }, next - new Date()); } if (process.env.DASHBOARD_NIGHTLY_REFRESH !== "false") scheduleNightly(); +notifyEvents.start(); +if (process.env.DASHBOARD_NIGHTLY_REFRESH !== "false") monthly.startSchedule(); // Publish approved posts when they're due (each client's own time zone). Every 2 minutes. if (process.env.SCHEDULER_ENABLED !== "false") { @@ -381,7 +604,7 @@ if (process.env.SCHEDULER_ENABLED !== "false") { const host = process.env.DASHBOARD_HOST || "127.0.0.1"; app.listen(PORT, host, () => { - console.log(`${AGENCY_NAME} dashboard on http://${host === "0.0.0.0" ? "localhost" : host}:${PORT}`); + console.log(`${workspace.agencyName()} dashboard on http://${host === "0.0.0.0" ? "localhost" : host}:${PORT}`); if (!users.hasUsers()) console.log("No logins yet. Open the dashboard to create the first admin."); if (!ODOO.url) console.log("Odoo not configured: the Team page will show setup steps."); }); diff --git a/dashboard/server/monthly.js b/dashboard/server/monthly.js new file mode 100644 index 0000000..c72c5a0 --- /dev/null +++ b/dashboard/server/monthly.js @@ -0,0 +1,194 @@ +// Monthly client report, built from real numbers (no AI needed): posts and results for the month, follower change, +// best posts and formats, how the client compares with tracked competitors, and next month's plan. +// Written as clients//reports/YYYY-MM-monthly.md, exported to the branded PDF, optionally emailed to the client. +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR } from "./config.js"; +import { agencyName, reportOptions } from "./workspace.js"; +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 plan from "../../connector/src/plan.js"; +import * as competitors from "../../connector/src/competitors.js"; +import { toPdf } from "../../tools/report-pdf/build.mjs"; + +const SETTINGS = path.join(DATA_DIR, "monthly.json"); +const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return d; } }; +const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); +const nf = new Intl.NumberFormat("en-CA"); +const n = (v) => (v == null ? "—" : nf.format(v)); +const PL = { instagram: "Instagram", facebook: "Facebook", tiktok: "TikTok", youtube: "YouTube", gbp: "Google Business Profile", linkedin: "LinkedIn", pinterest: "Pinterest", threads: "Threads" }; +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +export function settingsFor(slug) { return { auto: false, email: false, ...(readJson(SETTINGS, {})[slug] || {}) }; } +export function setSettings(slug, patch) { + clients.safeSlug(slug); + const all = readJson(SETTINGS, {}); + all[slug] = { ...settingsFor(slug), ...(patch.auto !== undefined ? { auto: Boolean(patch.auto) } : {}), ...(patch.email !== undefined ? { email: Boolean(patch.email) } : {}) }; + fs.writeFileSync(SETTINGS, JSON.stringify(all, null, 1)); + return all[slug]; +} + +const monthLabel = (m) => new Date(`${m}-15T12:00:00Z`).toLocaleDateString("en-CA", { month: "long", year: "numeric", timeZone: "UTC" }); +const lastMonth = () => { const d = new Date(); d.setUTCDate(1); d.setUTCMonth(d.getUTCMonth() - 1); return d.toISOString().slice(0, 7); }; +const nextMonthOf = (m) => { const d = new Date(`${m}-15T12:00:00Z`); d.setUTCMonth(d.getUTCMonth() + 1); return d.toISOString().slice(0, 7); }; +const endOf = (m) => { const d = new Date(`${m}-01T12:00:00Z`); d.setUTCMonth(d.getUTCMonth() + 1); d.setUTCDate(0); return d.toISOString().slice(0, 10); }; +const day = (iso) => new Date(`${String(iso).slice(0, 10)}T12:00:00Z`).toLocaleDateString("en-CA", { month: "short", day: "numeric", timeZone: "UTC" }); +const clean = (s, max = 110) => { const t = String(s || "").replace(/\s+/g, " ").replace(/[|]/g, "/").trim(); return t.length > max ? `${t.slice(0, max - 1)}…` : t; }; + +/** Build the report for one client and month (YYYY-MM, default last month). Returns the .md path relative to the client folder. */ +export function build(slug, month = lastMonth()) { + const dir = clients.safeSlug(slug); + if (!/^\d{4}-\d{2}$/.test(month)) throw err("Month must be YYYY-MM."); + const c = clients.getClient(slug); + const first = `${month}-01`, last = endOf(month); + const daysBack = Math.ceil((Date.now() - new Date(`${first}T00:00:00Z`)) / 86400000) + 1; + const items = published.feed({ client: slug, days: daysBack }).items.filter((i) => i.at && i.at.slice(0, 10) >= first && i.at.slice(0, 10) <= last); + const hist = metrics.readHistory(slug); + const inMonth = hist.filter((h) => h.date >= first && h.date <= last); + const before = [...hist].reverse().find((h) => h.date < first) || inMonth[0]; + const end = inMonth.at(-1); + const ig0 = before?.igFollowers, ig1 = end?.igFollowers, fb0 = before?.fbFollowers, fb1 = end?.fbFollowers; + const snap = metrics.readSnapshot(slug); + + const reach = items.filter((i) => i.metrics.reach != null).reduce((s, i) => s + i.metrics.reach, 0); + const inter = items.reduce((s, i) => s + (i.interactions || 0), 0); + const byPlatform = {}; + for (const i of items) byPlatform[i.platform] = (byPlatform[i.platform] || 0) + 1; + const top = [...items].filter((i) => i.interactions != null).sort((a, b) => b.interactions - a.interactions).slice(0, 3); + const byFormat = {}; + for (const i of items.filter((x) => x.platform === "instagram")) (byFormat[i.format] ||= []).push(i.interactions || 0); + const formatAvg = Object.entries(byFormat).map(([f, v]) => [f, Math.round(v.reduce((a, b) => a + b, 0) / v.length), v.length]).sort((a, b) => b[1] - a[1]); + const byDay = {}; + for (const i of items) (byDay[new Date(i.at).getDay()] ||= []).push(i.interactions || 0); + const bestDay = items.length < 6 ? null : Object.entries(byDay).filter(([, v]) => v.length >= 2).map(([d, v]) => [DAYS[d], v.reduce((a, b) => a + b, 0) / v.length]).sort((a, b) => b[1] - a[1])[0]; + + const own = snap?.ig ? { username: snap.ig.profile?.username, followers: snap.ig.profile?.followers_count, posts: snap.ig.posts || [], history: hist.map((h) => ({ date: h.date, followers: h.igFollowers })) } : null; + const comp = competitors.compare(slug, own); + const nm = nextMonthOf(month); + const upcoming = plan.calendar({ client: slug, from: `${nm}-01`, to: endOf(nm) }).items; + + const delta = (a, b) => (a == null || b == null ? null : b - a); + const igD = delta(ig0, ig1), fbD = delta(fb0, fb1); + const signed = (v) => (v == null ? "—" : `${v > 0 ? "+" : ""}${nf.format(v)}`); + const accounts = [snap?.ig?.profile?.username && `Instagram @${snap.ig.profile.username}`, snap?.fb?.page?.name && `Facebook ${snap.fb.page.name}`].filter(Boolean).join(" · "); + + const kpis = [ + `${n(items.length)} | Posts published | ${Object.entries(byPlatform).map(([p, k]) => `${k} ${PL[p] || p}`).join(", ") || "none this month"}`, + `${n(reach)} | Accounts reached on Instagram | summed across this month's posts`, + `${n(inter)} | Interactions | likes, comments, saves and shares`, + `${signed(igD)} | Instagram followers | ${ig1 != null ? `${n(ig1)} at month end` : "no data yet"}`, + ]; + const lines = []; + lines.push("---", `client: ${c?.name || slug}`, `report: Monthly social media report · ${monthLabel(month)}`, `period: ${day(first)} – ${day(last)}, ${month.slice(0, 4)}`); + if (accounts) lines.push(`accounts: ${accounts}`); + lines.push("kpis:", ...kpis.map((k) => ` - ${k}`), "---", ""); + + lines.push("## The month in short", ""); + const short = []; + short.push(items.length ? `We published ${items.length} post${items.length === 1 ? "" : "s"}${Object.keys(byPlatform).length > 1 ? ` across ${Object.keys(byPlatform).length} platforms` : ""}.` : "No posts went out this month."); + const fol = (k) => `${nf.format(k)} follower${k === 1 ? "" : "s"}`; + if (igD != null) short.push(igD > 0 ? `Instagram grew by ${fol(igD)}.` : igD < 0 ? `Instagram lost ${fol(-igD)}.` : "Instagram followers held steady."); + if (fbD != null && fbD !== 0) short.push(`Facebook ${fbD > 0 ? "gained" : "lost"} ${nf.format(Math.abs(fbD))} follower${Math.abs(fbD) === 1 ? "" : "s"}.`); + if (formatAvg.filter(([, , k]) => k >= 2).length > 1 && formatAvg[0][2] >= 2) short.push(`${formatAvg[0][0]}s did best on Instagram, with about ${nf.format(formatAvg[0][1])} interactions each.`); + if (bestDay) short.push(`${bestDay[0]} posts got the most engagement.`); + lines.push(short.join(" "), ""); + + if (top.length) { + lines.push("## Best posts", "", "| Date | Platform | Post | Reach | Interactions |", "|---|---|---|---|---|"); + for (const t of top) lines.push(`| ${day(t.at)} | ${PL[t.platform] || t.platform} | ${t.permalink ? `[${clean(t.caption, 70) || "View post"}](${t.permalink})` : clean(t.caption, 70)} | ${n(t.metrics.reach)} | ${n(t.interactions)} |`); + lines.push(""); + } + if (formatAvg.length) { + lines.push("## What worked on Instagram", "", "| Format | Posts | Average interactions |", "|---|---|---|"); + for (const [f, avg, k] of formatAvg) lines.push(`| ${f} | ${k} | ${n(avg)} |`); + lines.push(""); + } + if (items.length) { + lines.push("## Every post this month", "", "| Date | Platform | Format | Reach | Likes | Comments | Interactions |", "|---|---|---|---|---|---|---|"); + for (const i of [...items].sort((a, b) => a.at.localeCompare(b.at))) lines.push(`| ${day(i.at)} | ${PL[i.platform] || i.platform} | ${i.format} | ${n(i.metrics.reach)} | ${n(i.metrics.likes)} | ${n(i.metrics.comments)} | ${n(i.interactions)} |`); + lines.push(""); + } + if (comp.competitors.length) { + lines.push("## How you compare", "", "Engagement rate is a typical post's likes and comments divided by followers, so accounts of different sizes compare fairly.", "", + "| Account | Followers | Posts / week | Typical post | Engagement rate |", "|---|---|---|---|---|"); + for (const r of [...(comp.you ? [{ ...comp.you, username: comp.you.username || "you" }] : []), ...comp.competitors]) { + lines.push(`| ${r.own ? "**" : ""}@${r.username}${r.own ? " (you)**" : ""} | ${n(r.followers)} | ${r.postsPerWeek ?? "—"} | ${n(r.avgEngagement)} | ${r.engagementRate == null ? "—" : `${r.engagementRate}%`} |`); + } + lines.push(""); + } + lines.push(`## Coming up in ${monthLabel(nm)}`, ""); + if (upcoming.length) { + for (const u of upcoming.slice(0, 10)) lines.push(`- ${day(u.date)}: ${clean(u.title, 90)} (${u.stage === "published" ? "published" : u.stage === "scheduled" ? "scheduled" : "in planning"})`); + if (upcoming.length > 10) lines.push(`- …and ${upcoming.length - 10} more`); + } else lines.push("We'll share next month's plan with you for approval."); + lines.push("", `*Prepared by ${agencyName()}. Numbers come from Instagram and Facebook insights; reach isn't available for every platform.*`, ""); + + const reportsDir = path.join(dir, "reports"); + fs.mkdirSync(reportsDir, { recursive: true }); + const file = path.join(reportsDir, `${month}-monthly.md`); + fs.writeFileSync(file, lines.join("\n")); + return path.relative(dir, file).replace(/\\/g, "/"); +} + +/** Build and export the PDF. Returns { md, pdf } relative paths. */ +export function buildWithPdf(slug, month) { + const md = build(slug, month); + const dir = clients.safeSlug(slug); + const pdf = toPdf(path.join(dir, md), reportOptions()); + return { md, pdf: path.relative(dir, pdf).replace(/\\/g, "/") }; +} + +/** Email the month's PDF to the reviewer saved on the client's approval link. */ +export async function emailReport(slug, month = lastMonth()) { + const dir = clients.safeSlug(slug); + const link = portal.linkFor(slug); + if (!link.enabled || !link.contactEmail) throw err("Add the client's email first (client page → Client approval → Who reviews)."); + const pdf = path.join(dir, "reports", `${month}-monthly.pdf`); + if (!fs.existsSync(pdf)) throw err("Create the report first."); + const c = clients.getClient(slug); + const r = await notify.sendEmail({ + to: link.contactEmail, + subject: `${c?.name || "Your"} social media report · ${monthLabel(month)}`, + heading: `Your ${monthLabel(month)} social media report`, + lines: [`Hi${link.contactName ? ` ${link.contactName}` : ""}, attached is how ${c?.name || "your accounts"} did in ${monthLabel(month)}: what we posted, what worked, how you compare with competitors, and what's coming next month.`, "Reply to this email with any questions."], + fromName: agencyName(), + attachments: [{ filename: `${(c?.name || slug).replace(/[^\w-]+/g, "-")}-${month}-report.pdf`, path: pdf }], + }); + if (!r.sent) throw err(`Couldn't send: ${r.reason}`); + return { sent: true, to: link.contactEmail }; +} + +/** 1st of the month, 07:00: build last month's report for clients with auto on; email it where email is on. */ +export async function runMonthly() { + const month = lastMonth(); + const done = []; + for (const c of clients.listClients()) { + const s = settingsFor(c.slug); + if (!s.auto) continue; + try { + buildWithPdf(c.slug, month); + let sent = false; + if (s.email) { try { sent = (await emailReport(c.slug, month)).sent; } catch (e) { console.error("Monthly email", c.slug, e.message); } } + done.push(c.slug); + await notify.notify({ type: "monthly_report", level: "ok", title: `${c.name}: ${monthLabel(month)} report ready${sent ? " and emailed" : ""}`, link: `/clients/${c.slug}`, client: c.slug, to: "admins", email: false }); + } catch (e) { + await notify.notify({ type: "monthly_report", level: "bad", title: `${c.name}: monthly report failed`, body: e.message, link: `/clients/${c.slug}`, client: c.slug, to: "admins" }); + } + } + return { month, done }; +} + +export function startSchedule() { + const tick = () => { + const next = new Date(); next.setDate(1); next.setHours(7, 0, 0, 0); + if (next <= new Date()) { next.setMonth(next.getMonth() + 1); } + const wait = Math.min(next - new Date(), 2 ** 31 - 1); // setTimeout limit (~24 days); re-check then + setTimeout(async () => { if (new Date().getDate() === 1 && new Date().getHours() >= 7) { try { console.log("Monthly reports:", await runMonthly()); } catch (e) { console.error("Monthly reports failed", e); } } tick(); }, wait); + }; + tick(); +} + +export { lastMonth }; diff --git a/dashboard/server/notify-events.js b/dashboard/server/notify-events.js new file mode 100644 index 0000000..7942b6c --- /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 { 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 }; diff --git a/dashboard/server/notify.js b/dashboard/server/notify.js new file mode 100644 index 0000000..407a1d1 --- /dev/null +++ b/dashboard/server/notify.js @@ -0,0 +1,108 @@ +// 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 } from "./config.js"; +import { agencyName } from "./workspace.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 || agencyName())}

`; +} + +/** 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, attachments }) { + 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 || agencyName(), address: fromAddress() }, to, subject, replyTo, + text: [heading, "", ...lines, ...(button ? ["", `${button.label}: ${button.url}`] : [])].join("\n"), + html: html({ heading, lines, button, footer }), + attachments, + }); + 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/server/onboarding.js b/dashboard/server/onboarding.js new file mode 100644 index 0000000..16bfc32 --- /dev/null +++ b/dashboard/server/onboarding.js @@ -0,0 +1,39 @@ +// "Getting started" checklist for admins: each step is checked against the real state, not ticked by hand. +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR } from "./config.js"; +import * as users from "./users.js"; +import * as clients from "./clients.js"; +import * as notify from "./notify.js"; +import * as meta from "../../connector/src/meta.js"; +import * as sched from "../../connector/src/schedule.js"; +import * as lib from "../../connector/src/library.js"; +import * as extra from "../../connector/src/platforms/index.js"; + +const FILE = path.join(DATA_DIR, "onboarding.json"); +const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } }; + +export async function steps() { + let metaOk = false; + try { metaOk = (await meta.listAccounts()).length > 0; } catch { /* not connected */ } + const list = clients.listClients(); + const posts = sched.listPosts({}); + const portal = (() => { try { return Object.keys(JSON.parse(fs.readFileSync(path.join(DATA_DIR, "portal.json"), "utf8"))).length; } catch { return 0; } })(); + const workspace = (() => { try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, "workspace.json"), "utf8")); } catch { return {}; } })(); + const items = [ + { id: "workspace", title: "Name your workspace", text: "Your agency's name and logo appear in the app, on client approval pages and in reports.", done: Boolean(workspace.name), link: "/settings#workspace" }, + { id: "meta", title: "Connect Facebook and Instagram", text: "Add the Meta system user token to the server's .env (see the hosting guide). Every client's Page and Instagram account then shows up here.", done: metaOk, link: "/settings" }, + { id: "client", title: "Add your first client", text: "Pick their Facebook Page or Instagram account from the list.", done: list.length > 0, link: "/" }, + { id: "team", title: "Invite your team", text: "Add designers, editors and managers. Each gets their own login.", done: users.listUsers().length > 1, link: "/settings" }, + { id: "platforms", title: "Connect another platform", text: "TikTok, YouTube, Google Business Profile, LinkedIn and more, per client.", done: extra.vault.listConnections({}).length > 0, link: "/connections" }, + { id: "email", title: "Set up email", text: "So people get notified and clients get their approval emails and monthly reports.", done: notify.emailConfigured(), link: "/admin/health" }, + { id: "library", title: "Upload finished work", text: "Designers upload to the Content library; an admin reviews it.", done: lib.listItems({}).length > 0, link: "/library" }, + { id: "post", title: "Schedule and approve a post", text: "Create a draft, approve it, and Quorla publishes at the client's local time.", done: posts.some((p) => ["approved", "published", "partial"].includes(p.status)), link: "/publishing" }, + { id: "portal", title: "Give a client their approval link", text: "They approve posts or ask for changes without a login.", done: portal > 0, link: "/" }, + ]; + return { dismissed: Boolean(read().dismissed), done: items.filter((s) => s.done).length, total: items.length, items }; +} + +export function dismiss(value = true) { + fs.writeFileSync(FILE, JSON.stringify({ ...read(), dismissed: Boolean(value), at: new Date().toISOString() })); +} diff --git a/dashboard/server/portal.js b/dashboard/server/portal.js new file mode 100644 index 0000000..261b2a5 --- /dev/null +++ b/dashboard/server/portal.js @@ -0,0 +1,83 @@ +// Client approval portal: one private link per client (no login). The client sees posts the team sent for review, +// approves them or asks for changes. An admin still approves in Quorla before anything publishes. +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { DATA_DIR } from "./config.js"; +import { agencyName, logoPath as logoPathFn } from "./workspace.js"; +import * as clients from "./clients.js"; +import * as sched from "../../connector/src/schedule.js"; + +const FILE = path.join(DATA_DIR, "portal.json"); +const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } }; +const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE); }; +const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); + +/** Link settings for a client (admin view). */ +export function linkFor(slug) { + clients.safeSlug(slug); + const e = read()[slug]; + return e?.token ? { enabled: true, token: e.token, createdAt: e.createdAt, createdBy: e.createdBy, contactName: e.contactName || "", contactEmail: e.contactEmail || "" } : { enabled: false }; +} + +/** Create or replace the client's link (replacing makes the old one stop working). */ +export function issue(slug, by, { contactName = "", contactEmail = "" } = {}) { + clients.safeSlug(slug); + const db = read(); + db[slug] = { token: crypto.randomBytes(24).toString("base64url"), createdAt: new Date().toISOString(), createdBy: by, contactName: String(contactName).slice(0, 80), contactEmail: String(contactEmail).slice(0, 120) }; + write(db); + return linkFor(slug); +} + +export function updateContact(slug, { contactName, contactEmail }) { + const db = read(); + if (!db[slug]) throw err("Create the client's link first."); + if (contactName !== undefined) db[slug].contactName = String(contactName).slice(0, 80); + if (contactEmail !== undefined) { + const e = String(contactEmail).trim(); + if (e && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)) throw err("That email address doesn't look right."); + db[slug].contactEmail = e.slice(0, 120); + } + write(db); + return linkFor(slug); +} + +export function revoke(slug) { + const db = read(); delete db[slug]; write(db); +} + +/** Which client a token belongs to (constant-time compare), or null. */ +export function clientForToken(token) { + const t = Buffer.from(String(token || "")); + for (const [slug, e] of Object.entries(read())) { + const k = Buffer.from(e.token || ""); + if (k.length === t.length && crypto.timingSafeEqual(k, t)) return slug; + } + return null; +} + +const LIST_DAYS = 30; +/** What the client sees: posts waiting for them, and their recent answers. */ +export function view(slug) { + const c = clients.getClient(slug); + const since = Date.now() - LIST_DAYS * 86400000; + const posts = sched.listPosts({ client: slug }) + .filter((p) => p.clientReview && (p.clientReview.status === "pending" || new Date(p.clientReview.decidedAt || p.clientReview.requestedAt).getTime() >= since)) + .filter((p) => p.status !== "cancelled") + .map((p) => ({ + id: p.id, type: p.type, platforms: p.platforms, localTime: p.localTime, timezone: p.timezone, status: p.status, + caption: p.caption || "", captions: Object.fromEntries(Object.entries(p.options || {}).filter(([, o]) => o?.caption).map(([k, o]) => [k, o.caption])), + fbCaption: p.fbCaption || null, title: p.options?.youtube?.title || null, + media: (p.media || []).map((m, i) => ({ url: `media/${p.id}/${i}`, video: /\.(mp4|mov|m4v|webm)$/i.test(m) })), + review: { status: p.clientReview.status, note: p.clientReview.note || "", by: p.clientReview.by || null, decidedAt: p.clientReview.decidedAt || null }, + })); + return { client: c?.name || slug, agency: agencyName(), logo: Boolean(logoPathFn()), posts }; +} + +/** A media file of one of this client's posts that is in review (no other files are reachable). */ +export function mediaFile(slug, postId, index) { + const p = sched.listPosts({ client: slug }).find((x) => x.id === postId && x.clientReview); + const f = p?.media?.[Number(index)]; + if (!f || /^https?:/i.test(f) || !fs.existsSync(f)) return null; + return f; +} diff --git a/dashboard/server/portal/index.html b/dashboard/server/portal/index.html new file mode 100644 index 0000000..6990e83 --- /dev/null +++ b/dashboard/server/portal/index.html @@ -0,0 +1,137 @@ + + + + + + + +Posts for your approval + + + +
+
+ +

Posts for your approval

+

Loading…

+
+
+ + +
+ +
+ +
+
+
+ + + diff --git a/dashboard/server/published.js b/dashboard/server/published.js new file mode 100644 index 0000000..fc1aa96 --- /dev/null +++ b/dashboard/server/published.js @@ -0,0 +1,154 @@ +// "Published" feed: every post that went out on a client's accounts, with its numbers. +// Instagram and Facebook come from the nightly snapshots (so posts made outside Quorla show too); +// other platforms come from what Quorla published, with YouTube counts fetched on refresh. +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR } from "./config.js"; +import * as clients from "./clients.js"; +import * as metrics from "./metrics.js"; +import * as sched from "../../connector/src/schedule.js"; +import * as extra from "../../connector/src/platforms/index.js"; + +const STATS_FILE = path.join(DATA_DIR, "post-stats.json"); +const readStats = () => { try { return JSON.parse(fs.readFileSync(STATS_FILE, "utf8")); } catch { return { fetchedAt: null, posts: {} }; } }; + +const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null); +const sum = (...vs) => { const xs = vs.map(num).filter((x) => x !== null); return xs.length ? xs.reduce((a, b) => a + b, 0) : null; }; + +function igItem(client, p, viaQuorla) { + const i = p.insights || {}; + const likes = num(p.like_count), comments = num(p.comments_count), saves = num(i.saved), shares = num(i.shares); + return { + key: `instagram:${p.id}`, client: client.slug, clientName: client.name, platform: "instagram", + id: p.id, at: p.timestamp, caption: p.caption || "", permalink: p.permalink || null, + format: p.media_product_type === "REELS" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : p.media_type === "VIDEO" ? "Video" : "Image", + thumb: p.thumbnail_url || p.media_url || null, + metrics: { reach: num(i.reach), views: num(i.views), likes, comments, saves, shares }, + interactions: num(i.total_interactions) ?? sum(likes, comments, saves, shares), + viaQuorla, + }; +} + +function fbItem(client, p, viaQuorla) { + return { + key: `facebook:${p.id}`, client: client.slug, clientName: client.name, platform: "facebook", + id: p.id, at: p.created_time, caption: p.message || "", permalink: p.permalink_url || null, format: "Post", + thumb: p.picture || null, + metrics: { reach: null, views: null, likes: num(p.reactions), comments: num(p.comments), saves: null, shares: num(p.shares) }, + interactions: sum(p.reactions, p.comments, p.shares), + viaQuorla, + }; +} + +/** All published posts, newest first. Filters: client slug, platform id, days back. */ +export function feed({ client, platform, days = 30 } = {}) { + const since = Date.now() - Number(days || 30) * 86400000; + const all = clients.listClients().filter((c) => !client || c.slug === client); + const quorla = sched.listPosts({}).filter((p) => ["published", "partial"].includes(p.status)); + const quorlaIds = new Set(quorla.flatMap((p) => Object.values(p.results || {}).map((r) => r?.id).filter(Boolean).map(String))); + const stats = readStats().posts; + const items = []; + + for (const c of all) { + const snap = metrics.readSnapshot(c.slug); + for (const p of snap?.ig?.posts || []) items.push(igItem(c, p, quorlaIds.has(String(p.id)))); + for (const p of snap?.fb?.posts || []) items.push(fbItem(c, p, quorlaIds.has(String(p.id)))); + } + const seen = new Set(items.map((i) => i.key)); + const byClient = Object.fromEntries(all.map((c) => [c.slug, c])); + + // Platforms the snapshots don't cover, plus Quorla posts newer than the last snapshot. + for (const p of quorla) { + const c = byClient[p.client]; + if (!c) continue; + for (const [pl, r] of Object.entries(p.results || {})) { + if (!r?.id || r.cancelled) continue; + const key = `${pl}:${r.id}`; + if (seen.has(key)) continue; + const s = stats[key] || {}; + items.push({ + key, client: c.slug, clientName: c.name, platform: pl, id: String(r.id), + at: p.publishedAt || p.publishAt, caption: p.options?.[pl]?.caption ?? p.caption ?? "", permalink: r.permalink || null, + format: p.type === "REELS" ? "Video" : p.type === "CAROUSEL" ? "Carousel" : p.type === "TEXT" ? "Text" : "Image", + thumb: s.thumb || null, media: p.media?.[0] || null, + metrics: { reach: null, views: num(s.views), likes: num(s.likes), comments: num(s.comments), saves: null, shares: null }, + interactions: sum(s.likes, s.comments), + viaQuorla: true, note: r.inbox ? "Sent to the TikTok app to finish" : null, + }); + seen.add(key); + } + } + + const list = items + .filter((i) => (!platform || i.platform === platform) && i.at && new Date(i.at).getTime() >= since) + .sort((a, b) => new Date(b.at) - new Date(a.at)); + const reachKnown = list.filter((i) => i.metrics.reach !== null); + return { + items: list, + totals: { + posts: list.length, + viaQuorla: list.filter((i) => i.viaQuorla).length, + reach: reachKnown.reduce((s, i) => s + i.metrics.reach, 0), + interactions: list.reduce((s, i) => s + (i.interactions || 0), 0), + avgInteractions: list.length ? Math.round(list.reduce((s, i) => s + (i.interactions || 0), 0) / list.length) : 0, + }, + snapshotsAt: Object.fromEntries(all.map((c) => [c.slug, metrics.readSnapshot(c.slug)?.fetchedAt || null])), + statsAt: readStats().fetchedAt, + }; +} + +/** Fetch counts for Quorla-published posts on platforms with a stats reader (YouTube), last 90 days. */ +export async function refreshStats() { + const since = Date.now() - 90 * 86400000; + const want = {}; + for (const p of sched.listPosts({})) { + if (!["published", "partial"].includes(p.status) || new Date(p.publishedAt || p.publishAt).getTime() < since) continue; + for (const [pl, r] of Object.entries(p.results || {})) { + if (pl !== "youtube" || !r?.id) continue; + ((want[p.client] ||= {})[pl] ||= []).push(String(r.id)); + } + } + const out = readStats(); + const errors = []; + for (const [client, byPl] of Object.entries(want)) { + for (const [pl, ids] of Object.entries(byPl)) { + try { + const s = await extra.postStats(pl, client, ids); + for (const [id, v] of Object.entries(s)) out.posts[`${pl}:${id}`] = v; + } catch (e) { errors.push(`${client} ${pl}: ${e.message}`); } + } + } + out.fetchedAt = new Date().toISOString(); + fs.writeFileSync(STATS_FILE, JSON.stringify(out, null, 1)); + return { fetchedAt: out.fetchedAt, errors }; +} + +const WINDOWS = [[6, 9], [9, 12], [12, 15], [15, 18], [18, 21], [21, 24]]; +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const median = (xs) => { const s = [...xs].sort((a, b) => a - b); const m = s.length >> 1; return s.length ? (s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2) : null; }; + +/** + * When this client's Instagram posts do best, from their own last 120 days: each post's reach divided by the + * client's median reach, grouped by weekday and 3-hour window (client's local time). Needs 8+ posts with reach. + */ +export function bestTimes(slug, timezone) { + const posts = feed({ client: slug, platform: "instagram", days: 120 }).items.filter((i) => i.metrics.reach != null && i.metrics.reach > 0); + if (posts.length < 8) return { basedOn: posts.length, slots: [], times: [] }; + const base = median(posts.map((p) => p.metrics.reach)); + const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false }); + const groups = {}; + for (const p of posts) { + const parts = Object.fromEntries(fmt.formatToParts(new Date(p.at)).map((x) => [x.type, x.value])); + const hour = Number(parts.hour) % 24, minute = Number(parts.minute); + const w = WINDOWS.findIndex(([a, b]) => hour >= a && hour < b); + if (w < 0) continue; + const day = WEEKDAYS.indexOf(parts.weekday.slice(0, 3)); + (groups[`${day}-${w}`] ||= { day, w, ratios: [], minutes: [] }).ratios.push(p.metrics.reach / base); + groups[`${day}-${w}`].minutes.push(hour * 60 + minute); + } + const hhmm = (m) => `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(Math.round(m % 60 / 15) * 15 % 60).padStart(2, "0")}`; + const slots = Object.values(groups).filter((g) => g.ratios.length >= 2) + .map((g) => ({ day: WEEKDAYS[g.day], window: `${String(WINDOWS[g.w][0]).padStart(2, "0")}:00–${String(WINDOWS[g.w][1] % 24).padStart(2, "0")}:00`, time: hhmm(median(g.minutes)), posts: g.ratios.length, lift: Math.round((median(g.ratios) - 1) * 100) })) + .filter((s) => s.lift > 0).sort((a, b) => b.lift - a.lift).slice(0, 3); + return { basedOn: posts.length, medianReach: base, slots, times: [...new Set(slots.map((s) => s.time))] }; +} diff --git a/dashboard/server/workspace.js b/dashboard/server/workspace.js new file mode 100644 index 0000000..3b70b30 --- /dev/null +++ b/dashboard/server/workspace.js @@ -0,0 +1,53 @@ +// Workspace (white-label): the agency's name, logo, brand colour and support email. +// Used in the app (sidebar, sign-in), client approval pages, emails and the PDF reports. +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR, AGENCY_NAME } from "./config.js"; + +const FILE = path.join(DATA_DIR, "workspace.json"); +const LOGO = path.join(DATA_DIR, "workspace-logo"); +const HEX = /^#[0-9a-f]{6}$/i; +const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); +const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } }; + +export function get() { + const w = read(); + return { name: w.name || "", accent: w.accent || "", supportEmail: w.supportEmail || "", website: w.website || "", hasLogo: Boolean(logoPath()), updatedAt: w.updatedAt || null }; +} + +/** The name shown to people: the workspace name, else AGENCY_NAME from .env. */ +export const agencyName = () => read().name || AGENCY_NAME; + +export function save(patch, by) { + const cur = read(); + const next = { ...cur }; + if (patch.name !== undefined) next.name = String(patch.name).trim().slice(0, 60); + if (patch.accent !== undefined) { if (patch.accent && !HEX.test(patch.accent)) throw err("Brand colour must be like #242142."); next.accent = patch.accent || ""; } + if (patch.supportEmail !== undefined) { const e = String(patch.supportEmail).trim(); if (e && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)) throw err("That email doesn't look right."); next.supportEmail = e; } + if (patch.website !== undefined) { const u = String(patch.website).trim(); if (u && !/^https?:\/\//i.test(u)) throw err("Website must start with https://"); next.website = u; } + next.updatedAt = new Date().toISOString(); next.updatedBy = by; + fs.writeFileSync(FILE, JSON.stringify(next, null, 1)); + return get(); +} + +export function logoPath() { + for (const ext of [".png", ".jpg"]) if (fs.existsSync(LOGO + ext)) return LOGO + ext; + return null; +} + +/** Save an uploaded logo (PNG or JPG, up to 2 MB). */ +export function saveLogo(buf, filename) { + if (!Buffer.isBuffer(buf) || !buf.length) throw err("Choose an image."); + if (buf.length > 2 * 1024 * 1024) throw err("Logo must be 2 MB or smaller."); + const png = buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + const jpg = buf[0] === 0xff && buf[1] === 0xd8; + if (!png && !jpg) throw err("Logo must be a PNG or JPG."); + removeLogo(); + fs.writeFileSync(LOGO + (png ? ".png" : ".jpg"), buf); + return get(); +} + +export function removeLogo() { for (const ext of [".png", ".jpg"]) fs.rmSync(LOGO + ext, { force: true }); } + +/** Options for tools/report-pdf toPdf(): the agency's logo when one is set. */ +export const reportOptions = () => (logoPath() ? { logoPath: logoPath(), logoAlt: agencyName() } : {}); diff --git a/dashboard/web/src/App.jsx b/dashboard/web/src/App.jsx index 0359efb..cff50d2 100644 --- a/dashboard/web/src/App.jsx +++ b/dashboard/web/src/App.jsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom"; -import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug } from "lucide-react"; +import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays, Swords, Activity, History, Building2 } from "lucide-react"; import { api } from "./api.js"; import { cx, Spinner } from "./ui.jsx"; import Login from "./pages/Login.jsx"; @@ -10,6 +10,11 @@ import Team from "./pages/Team.jsx"; import Runs from "./pages/Runs.jsx"; import Settings from "./pages/Settings.jsx"; 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 { Agencies, Audit, Health } from "./pages/Admin.jsx"; import Inbox from "./pages/Inbox.jsx"; import Library from "./pages/Library.jsx"; import Connections from "./pages/Connections.jsx"; @@ -17,28 +22,50 @@ import Connections from "./pages/Connections.jsx"; const AuthCtx = createContext(null); export const useAuth = () => useContext(AuthCtx); +/** White-label: the agency's brand colour replaces Quorla's navy for buttons, links and highlights. */ +function applyAccent(hex) { + const root = document.documentElement.style; + if (/^#[0-9a-f]{6}$/i.test(hex || "")) { + root.setProperty("--color-brand", hex); + root.setProperty("--color-brand-deep", `color-mix(in srgb, ${hex} 78%, black)`); + root.setProperty("--color-brand-soft", `color-mix(in srgb, ${hex} 10%, white)`); + } else for (const k of ["--color-brand", "--color-brand-deep", "--color-brand-soft"]) root.removeProperty(k); +} + const NAV = [ { to: "/", label: "Clients", icon: LayoutGrid, end: true }, + { to: "/calendar", label: "Content calendar", icon: CalendarDays }, { to: "/library", label: "Content library", icon: FolderUp }, { to: "/publishing", label: "Publishing", icon: CalendarClock }, + { to: "/published", label: "Published", icon: BarChart3 }, + { to: "/competitors", label: "Competitors", icon: Swords }, { to: "/inbox", label: "Inbox", icon: MessageCircle }, - { to: "/connections", label: "Connections", icon: Plug }, + { to: "/connections", label: "Connections", icon: Plug, group: "Setup" }, { to: "/team", label: "Team", icon: Users2 }, { to: "/runs", label: "AI runs", icon: Sparkles }, - { to: "/settings", label: "Settings", icon: Settings2 }, + { to: "/admin/health", label: "System health", icon: Activity, admin: true, group: "Admin" }, + { to: "/admin/audit", label: "Audit log", icon: History, admin: true }, + { to: "/admin/agencies", label: "Agencies", icon: Building2, admin: true, control: true }, + { to: "/settings", label: "Settings", icon: Settings2, group: "" }, ]; function Shell({ children }) { - const { user, signOut, agency } = useAuth(); + const { user, signOut, agency, workspace, control } = useAuth(); return (