// 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 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 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"))); // 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/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); })); // ---------- 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) => { const item = lib.createItem({ ...req.body, uploadedBy: 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"; 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", 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); } scheduleNightly(); }, next - new Date()); } if (process.env.DASHBOARD_NIGHTLY_REFRESH !== "false") scheduleNightly(); // 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."); });