// Agency dashboard API + static web app. import fs from "node:fs"; import path from "node:path"; import express from "express"; import { fileURLToPath } from "node:url"; import { PORT, WEB_DIST, AGENCY_NAME, BRANDING_DIR, ODOO } from "./config.js"; import { login, logout, currentUser, requireAuth, requireAdmin, setupFirstAdmin, issueSession } from "./auth.js"; 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 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 extra from "../../connector/src/platforms/index.js"; import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js"; import { DATA_DIR as _DD } from "./config.js"; 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"); app.use(express.json({ limit: "200kb" })); app.use((_req, res, next) => { res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "DENY"); res.setHeader("Referrer-Policy", "same-origin"); next(); }); // Wrap async handlers so thrown errors become JSON responses. const h = (fn) => async (req, res) => { try { const out = await fn(req, res); if (out !== undefined && !res.headersSent) res.json(out); } catch (e) { const status = e.status || 500; if (status >= 500) console.error(`[${req.method} ${req.path}]`, e); if (!res.headersSent) res.status(status).json({ error: e.message || "Something went wrong." }); } }; // ---------- public ---------- 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 (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"))); app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-mark-app.png"))); app.get("/brand/icon.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-icon-1024.png"))); // Public legal pages (linked from platform app listings: TikTok, Google, LinkedIn, Pinterest, Meta). const LEGAL_DIR = fileURLToPath(new URL("./legal/", import.meta.url)); app.get("/privacy", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "privacy.html"))); 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 }); } }); // 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); if (!f || !fs.existsSync(f)) return res.status(404).send("Not found"); res.setHeader("Cache-Control", "private, max-age=3600"); res.sendFile(f); }); // OAuth: the platform sends the person back here after they approve Quorla. const baseUrl = (req) => publicBaseUrl() || `${req.protocol}://${req.get("host")}`; const redirectUri = (req, platform) => `${baseUrl(req)}/oauth/${platform}/callback`; app.get("/oauth/:platform/callback", async (req, res) => { const back = (q) => res.redirect(`/connections?${new URLSearchParams(q)}`); const user = currentUser(req); if (!user) return back({ error: "Your Quorla session ended. Sign in and connect again." }); if (req.query.error) return back({ error: `${req.params.platform}: ${String(req.query.error_description || req.query.error).slice(0, 300)}` }); try { const conn = await extra.finishConnect({ platform: req.params.platform, code: String(req.query.code || ""), state: String(req.query.state || ""), user }); back({ client: conn.client, connected: req.params.platform }); } catch (e) { console.error("[oauth]", req.params.platform, e.message); back({ error: String(e.message).slice(0, 300) }); } }); // ---------- everything below needs a session ---------- app.use("/api", requireAuth); // ---------- your account (everyone) ---------- app.patch("/api/me", h(async (req) => ({ user: users.renameSelf(req.user.id, req.body?.name) }))); app.post("/api/me/password", h(async (req, res) => { const { v, ...user } = users.changeOwnPassword(req.user.id, req.body?.current, req.body?.next); issueSession(res, { id: user.id, v }); // other devices are signed out; this one stays in return { user }; })); // ---------- platforms beyond Facebook/Instagram ---------- app.get("/api/platforms", h(async (req) => ({ platforms: extra.describePlatforms().map((p) => ({ ...p, redirectUri: redirectUri(req, p.id), app: req.user.role === "admin" ? p.app : { configured: p.app.configured } })), publicUrl: baseUrl(req), mediaLinks: Boolean(publicBaseUrl()), }))); app.put("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.saveApp(req.params.id, req.body || {}, req.user.name || req.user.email); return { ok: true }; })); app.delete("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.removeApp(req.params.id); return { ok: true }; })); app.get("/api/connections", h(async (req) => ({ connections: extra.vault.listConnections({ client: req.query.client || undefined }) }))); app.get("/api/quota", h(async (req) => ({ quota: sched.quotaSummary(clients.safeSlug(req.query.client) && String(req.query.client)) }))); app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => { const client = String(req.query.client || ""); if (!clients.getClient(client)) throw Object.assign(new Error("Pick a client first."), { status: 400 }); return { url: extra.startConnect({ platform: req.params.platform, client, redirectUri: redirectUri(req, req.params.platform), user: req.user }) }; })); app.post("/api/connections/:id/check", requireAdmin, h(async (req) => ({ connection: await extra.checkConnection(req.params.id) }))); app.get("/api/connections/:id/creator-info", h(async (req) => ({ creator: await extra.creatorInfo(req.params.id) }))); app.get("/api/connections/:id/targets", h(async (req) => ({ targets: await extra.targetsFor(req.params.id) }))); app.patch("/api/connections/:id", requireAdmin, h(async (req) => { if (req.body?.client !== undefined) { const to = String(req.body.client || ""); if (!clients.getClient(to)) throw Object.assign(new Error("Pick a client to move it to."), { status: 400 }); return { connection: extra.vault.moveConnection(req.params.id, to, req.user.name || req.user.email) }; } return { connection: await extra.setTarget(req.params.id, String(req.body?.targetId || "")) }; })); app.delete("/api/connections/:id", requireAdmin, h(async (req) => { extra.vault.removeConnection(req.params.id); return { ok: true }; })); // ---------- team members (admins) ---------- app.get("/api/users", requireAdmin, h(async () => ({ users: users.listUsers() }))); app.post("/api/users", requireAdmin, h(async (req) => users.createUser(req.body || {}, req.user.name || req.user.email))); app.patch("/api/users/:id", requireAdmin, h(async (req) => ({ user: users.updateUser(req.params.id, req.body || {}, req.user.id) }))); app.post("/api/users/:id/reset-password", requireAdmin, h(async (req) => users.resetPassword(req.params.id))); app.delete("/api/users/:id", requireAdmin, h(async (req) => users.removeUser(req.params.id, req.user.id))); app.get("/api/status", h(async () => { let metaStatus; try { const accounts = await meta.listAccounts(); metaStatus = { connected: true, accounts: accounts.length }; } catch (e) { metaStatus = { connected: false, error: e.message }; } return { meta: metaStatus, odoo: await odoo.status(), ai: { model: process.env.DASHBOARD_AI_MODEL || "claude-opus-5-5", apiKey: Boolean(process.env.ANTHROPIC_API_KEY) }, publishing: process.env.ALLOW_PUBLISH === "true", }; })); // Overview: every client with its last snapshot, plus things that need attention. app.get("/api/overview", h(async () => { const list = clients.listClients().map(metrics.summary); const attention = []; for (const c of list) { if (!c.linked) attention.push({ slug: c.slug, name: c.name, level: "warn", text: "Not linked to a Facebook Page yet." }); if (c.daysSinceLastPost != null && c.daysSinceLastPost >= 7) attention.push({ slug: c.slug, name: c.name, level: c.daysSinceLastPost >= 14 ? "bad" : "warn", text: `No Instagram post in ${c.daysSinceLastPost} days.` }); if (c.igLinkTaps28 === 0 && c.igReach28 > 0) attention.push({ slug: c.slug, name: c.name, level: "warn", text: "0 profile link taps in 28 days." }); if (c.linked && (!c.lastAuditAt || Date.now() - new Date(c.lastAuditAt) > 35 * 86400 * 1000)) attention.push({ slug: c.slug, name: c.name, level: "info", text: c.lastAuditAt ? "Monthly audit is due." : "No audit yet." }); for (const e of c.errors) attention.push({ slug: c.slug, name: c.name, level: "bad", text: e }); } const sum = (k) => list.reduce((s, c) => s + (c[k] || 0), 0); return { clients: list, attention, totals: { clients: list.length, linked: list.filter((c) => c.linked).length, igFollowers: sum("igFollowers"), igReach28: sum("igReach28"), fbFollowers: sum("fbFollowers"), postsLast28: sum("postsLast28"), }, }; })); app.post("/api/refresh-all", h(async () => { metrics.refreshAll().catch((e) => console.error("refresh-all", e)); return { started: true }; })); // ---------- clients ---------- app.get("/api/clients/:slug", h(async (req) => { const client = clients.getClient(req.params.slug); if (!client) throw Object.assign(new Error("Unknown client."), { status: 404 }); return { client, snapshot: metrics.readSnapshot(client.slug), refreshing: metrics.isRefreshing(client.slug), history: metrics.readHistory(client.slug), docs: clients.listDocs(client.slug), jobs: jobs.listJobs({ slug: client.slug }).slice(0, 10), }; })); app.post("/api/clients/:slug/refresh", h(async (req) => { clients.safeSlug(req.params.slug); if (!clients.getClient(req.params.slug)?.linked) throw Object.assign(new Error("This client isn't linked to a Facebook Page or Instagram account yet, so there are no numbers to fetch. Link an account first."), { status: 400 }); const wait = req.query.wait === "1"; const p = metrics.refreshSnapshot(req.params.slug); if (wait) return { snapshot: await p }; p.catch((e) => console.error("refresh", req.params.slug, e.message)); return { started: true }; })); app.patch("/api/clients/:slug/link", requireAdmin, h(async (req) => { const allowed = ["odooProject", "name"]; const patch = Object.fromEntries(Object.entries(req.body || {}).filter(([k]) => allowed.includes(k))); return { link: clients.saveLink(req.params.slug, patch) }; })); app.post("/api/clients/:slug/link-page", requireAdmin, h(async (req) => { const link = await clients.linkToAccount(req.params.slug, req.body?.accountId || req.body?.pageId); metrics.refreshSnapshot(req.params.slug).catch((e) => console.error("first refresh", req.params.slug, e.message)); return { link }; })); app.get("/api/accounts/unlinked", h(async (req) => ({ accounts: await clients.unlinkedAccounts({ refresh: req.query.refresh === "1" }) }))); app.post("/api/clients", requireAdmin, h(async (req) => { const client = await clients.addClientFromAccount(req.body?.accountId || req.body?.pageId, { name: req.body?.name }); metrics.refreshSnapshot(client.slug).catch((e) => console.error("first refresh", client.slug, e.message)); if (req.body?.runAudit) jobs.createJob({ type: "account-audit", slug: client.slug }, req.user); return { client }; })); // ---------- documents ---------- app.get("/api/clients/:slug/doc", h(async (req) => docs.renderDoc(req.params.slug, req.query.path))); app.post("/api/clients/:slug/doc/pdf", h(async (req) => ({ pdf: docs.exportPdf(req.params.slug, req.body?.path) }))); app.get("/api/clients/:slug/file", h(async (req, res) => { const full = docs.resolveDoc(req.params.slug, req.query.path); if (req.query.download === "1") res.download(full, path.basename(full)); else res.sendFile(full); })); // ---------- AI jobs ---------- app.get("/api/jobs", h(async (req) => ({ jobs: jobs.listJobs({ slug: req.query.slug }), types: Object.fromEntries(Object.entries(jobs.JOB_TYPES).map(([k, v]) => [k, { label: v.label, needsClient: v.needsClient }])) }))); app.get("/api/jobs/:id", h(async (req) => { const job = jobs.getJob(req.params.id); if (!job) throw Object.assign(new Error("Job not found."), { status: 404 }); return { job }; })); app.post("/api/jobs", h(async (req) => { if (req.body?.type === "custom" && req.user.role !== "admin") throw Object.assign(new Error("Only admins can run custom requests."), { status: 403 }); return { job: jobs.createJob(req.body || {}, req.user) }; })); app.post("/api/jobs/:id/cancel", h(async (req) => ({ job: jobs.cancelJob(req.params.id) }))); // ---------- team (Odoo) ---------- app.get("/api/team", h(async (req) => { const status = await odoo.status(); if (!status.connected) return { status, members: [], tasks: [], projects: [], stages: [] }; const projectIds = req.query.project ? [req.query.project] : []; const [tasks, workload, projects, stages] = await Promise.all([odoo.tasks({ projectIds }), odoo.workload(), odoo.projects(), odoo.stages()]); return { status, workload, tasks, projects, stages }; })); app.post("/api/team/tasks", h(async (req) => ({ task: await odoo.createTask(req.body || {}) }))); app.post("/api/team/tasks/:id/stage", h(async (req) => odoo.moveTask(req.params.id, req.body?.stageId))); // ---------- publishing: content queue ---------- const MEDIA_DIR = path.join(path.dirname(_DD), "schedule", "media"); fs.mkdirSync(MEDIA_DIR, { recursive: true }); const clientAccount = async (slug) => { const c = clients.getClient(slug); if (!c?.linked) throw Object.assign(new Error("This client isn't linked to a Facebook Page or Instagram account yet."), { status: 400 }); const a = await meta.accountForLink(c); if (!a) throw Object.assign(new Error("The agency can't see this client's account."), { status: 400 }); return a; }; 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.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 }); const file = path.join(MEDIA_DIR, `${Date.now()}-${name}`); fs.writeFileSync(file, req.body); return { path: file, name, size: req.body.length }; })); app.post("/api/schedule", h(async (req) => ({ post: sched.createPost({ ...req.body, createdBy: req.user.name || req.user.email }) }))); 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 || "")); if (!(f.startsWith(path.resolve(MEDIA_DIR)) || lib.isLibraryFile(f)) || !fs.existsSync(f)) throw Object.assign(new Error("Not found."), { status: 404 }); res.sendFile(f); })); // ---------- 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())))); // ---------- 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]); app.get("/api/library", h(async (req) => ({ items: lib.listItems({ client: req.query.client || undefined, status: req.query.status || undefined }), summary: lib.summary() }))); app.post("/api/library/upload", h(async (req) => { const file = lib.uploadTarget(clients.safeSlug(req.headers["x-client"]) && req.headers["x-client"], decodeURIComponent(String(req.headers["x-filename"] || ""))); await new Promise((resolve, reject) => { const out = fs.createWriteStream(file); req.pipe(out); out.on("finish", resolve); out.on("error", reject); req.on("error", reject); }); return { path: file, size: fs.statSync(file).size }; })); app.post("/api/library", h(async (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 }; })); app.patch("/api/library/:id", h(async (req) => { const item = lib.updateItem(req.params.id, req.body || {}, who(req)); if (req.body?.files) await odoo.taskActivity(item.taskRef, { note: `${esc(who(req))} uploaded version ${item.version} of ${esc(item.title)}.
`, stageMatch: "review" }); return { item }; })); 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 }; })); 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) => { const f = lib.isLibraryFile(req.query.path); if (!f) throw Object.assign(new Error("Not found."), { status: 404 }); res.sendFile(f); })); // ---------- 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) }) }; })); app.post("/api/inbox/reply", h(async (req) => { needPublishing(); const { client, platform, commentId, message } = req.body || {}; if (!message?.trim()) throw Object.assign(new Error("Write a reply first."), { status: 400 }); const a = await clientAccount(client); const r = platform === "instagram" ? await meta.igReplyComment(commentId, message.trim()) : await meta.fbReplyComment(a, commentId, message.trim()); fs.appendFileSync(path.join(path.dirname(_DD), "publish-log.jsonl"), JSON.stringify({ at: new Date().toISOString(), action: "reply", by: req.user.email, client, platform, commentId, message, result: r }) + "\n"); return { ok: true, result: r }; })); app.post("/api/inbox/hide", h(async (req) => { needPublishing(); const { client, platform, commentId, hide = true } = req.body || {}; const a = await clientAccount(client); return { ok: true, result: platform === "instagram" ? await meta.igHideComment(commentId, hide) : await meta.fbHideComment(a, commentId, hide) }; })); // ---------- research ---------- app.get("/api/clients/:slug/competitor", h(async (req) => { const a = await clientAccount(req.params.slug); if (!a.instagram) throw Object.assign(new Error("No Instagram linked."), { status: 400 }); return { competitor: await meta.igBusinessDiscovery(a.instagram.id, req.query.username) }; })); app.get("/api/clients/:slug/audience", h(async (req) => { const a = await clientAccount(req.params.slug); if (!a.instagram) throw Object.assign(new Error("No Instagram linked."), { status: 400 }); return { audience: await meta.igAudience(a.instagram.id) }; })); // ---------- web app ---------- if (fs.existsSync(WEB_DIST)) { app.use(express.static(WEB_DIST, { index: false, maxAge: "1h" })); app.get(/^(?!\/api\/).*/, (_req, res) => res.sendFile(path.join(WEB_DIST, "index.html"))); } app.use("/api", (_req, res) => res.status(404).json({ error: "Not found." })); // ---------- schedule ---------- // Nightly (03:00 server time) refresh of every client's numbers so the morning view is current. function scheduleNightly() { const next = new Date(); next.setHours(3, 0, 0, 0); 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") { setInterval(() => sched.runDue().then((r) => r.published?.length && console.log("Scheduler:", JSON.stringify(r))).catch((e) => console.error("Scheduler failed", e)), 120000); } 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}`); 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."); });