- Control centre (QUORLA_CONTROL=true on app.quorlasocial.com): public /signup → Stripe Checkout, verified Stripe webhook (paid, updated, cancelled, payment failed), Admin → Agencies (approve without Stripe, suspend, resume, billing portal, resend welcome, remove) - deploy/provision-tenants.sh (cron): one container per agency with its own .env and data, nginx map for <agency>.quorlasocial.com, upgrades agencies after a rebuild, archives removed data - New workspaces: first admin only via the emailed one-time setup link (SETUP_TOKEN) - Meta system user token can be saved in Settings (encrypted) for workspaces that can't edit .env - docs/multi-tenant.md: DNS, wildcard cert, nginx, sudoers, cron, Stripe setup; tests for the control centre Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
611 lines
41 KiB
JavaScript
611 lines
41 KiB
JavaScript
// 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 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";
|
|
|
|
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");
|
|
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) => { 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")));
|
|
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 }); }
|
|
});
|
|
|
|
// 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/<path> and counted redirects /l/<path>/go/<link id>.
|
|
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);
|
|
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);
|
|
app.use("/api", activity.middleware);
|
|
|
|
// ---------- 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);
|
|
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 });
|
|
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()))));
|
|
|
|
// ---------- 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]);
|
|
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: `<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";
|
|
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 ? `<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 };
|
|
}));
|
|
// 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) => {
|
|
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(`${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.");
|
|
});
|