metatroncubeswdev c309581df0 Initial commit: Quorla social media manager
Dashboard (content library, publishing queue with approvals, inbox, team
management, AI runs), Meta connector, branded PDF reports, agent skills,
and Docker packaging (Dockerfile, docker-compose with Caddy HTTPS,
backup and path-relocation scripts).

Client data, live data and secrets are excluded via .gitignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:02:29 -04:00

317 lines
17 KiB
JavaScript

// Agency dashboard API + static web app.
import fs from "node:fs";
import path from "node:path";
import express from "express";
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 { 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.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-logo.png")));
app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-mark.png")));
// ---------- 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 };
}));
// ---------- 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);
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.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?.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?.pageId) throw Object.assign(new Error("This client isn't linked to a Facebook Page yet."), { status: 400 });
const a = (await meta.listAccounts()).find((x) => x.page_id === c.pageId);
if (!a) throw Object.assign(new Error("The agency can't see this client's Page."), { 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/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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[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: `<p>${esc(who(req))} uploaded <b>${esc(item.title)}</b> (${item.format.toLowerCase()}, ${item.files.length} file${item.files.length > 1 ? "s" : ""}) to the Social Manager library for review.</p>`, 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: `<p>${esc(who(req))} uploaded version ${item.version} of <b>${esc(item.title)}</b>.</p>`, 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 ? `<p>Approved by ${esc(who(req))}: <b>${esc(item.title)}</b>.</p>` : `<p>Changes requested by ${esc(who(req))}: ${esc(item.reviewNote)}</p>`, 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.");
});