- Settings → Workspace: agency name, logo, brand colour, support email, website - Logo replaces Quorla's in the app (with 'Powered by Quorla'), sign-in page, client approval page and PDF reports; brand colour drives buttons, links and highlights; emails and reports use the agency name - Getting started checklist on the Clients page for admins, checked against the real state (workspace, Meta, first client, team, platforms, email, library, first post, approval link); dismissible Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
84 lines
4.1 KiB
JavaScript
84 lines
4.1 KiB
JavaScript
// Client approval portal: one private link per client (no login). The client sees posts the team sent for review,
|
|
// approves them or asks for changes. An admin still approves in Quorla before anything publishes.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import crypto from "node:crypto";
|
|
import { DATA_DIR } from "./config.js";
|
|
import { agencyName, logoPath as logoPathFn } from "./workspace.js";
|
|
import * as clients from "./clients.js";
|
|
import * as sched from "../../connector/src/schedule.js";
|
|
|
|
const FILE = path.join(DATA_DIR, "portal.json");
|
|
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
|
|
const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE); };
|
|
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
|
|
|
/** Link settings for a client (admin view). */
|
|
export function linkFor(slug) {
|
|
clients.safeSlug(slug);
|
|
const e = read()[slug];
|
|
return e?.token ? { enabled: true, token: e.token, createdAt: e.createdAt, createdBy: e.createdBy, contactName: e.contactName || "", contactEmail: e.contactEmail || "" } : { enabled: false };
|
|
}
|
|
|
|
/** Create or replace the client's link (replacing makes the old one stop working). */
|
|
export function issue(slug, by, { contactName = "", contactEmail = "" } = {}) {
|
|
clients.safeSlug(slug);
|
|
const db = read();
|
|
db[slug] = { token: crypto.randomBytes(24).toString("base64url"), createdAt: new Date().toISOString(), createdBy: by, contactName: String(contactName).slice(0, 80), contactEmail: String(contactEmail).slice(0, 120) };
|
|
write(db);
|
|
return linkFor(slug);
|
|
}
|
|
|
|
export function updateContact(slug, { contactName, contactEmail }) {
|
|
const db = read();
|
|
if (!db[slug]) throw err("Create the client's link first.");
|
|
if (contactName !== undefined) db[slug].contactName = String(contactName).slice(0, 80);
|
|
if (contactEmail !== undefined) {
|
|
const e = String(contactEmail).trim();
|
|
if (e && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)) throw err("That email address doesn't look right.");
|
|
db[slug].contactEmail = e.slice(0, 120);
|
|
}
|
|
write(db);
|
|
return linkFor(slug);
|
|
}
|
|
|
|
export function revoke(slug) {
|
|
const db = read(); delete db[slug]; write(db);
|
|
}
|
|
|
|
/** Which client a token belongs to (constant-time compare), or null. */
|
|
export function clientForToken(token) {
|
|
const t = Buffer.from(String(token || ""));
|
|
for (const [slug, e] of Object.entries(read())) {
|
|
const k = Buffer.from(e.token || "");
|
|
if (k.length === t.length && crypto.timingSafeEqual(k, t)) return slug;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const LIST_DAYS = 30;
|
|
/** What the client sees: posts waiting for them, and their recent answers. */
|
|
export function view(slug) {
|
|
const c = clients.getClient(slug);
|
|
const since = Date.now() - LIST_DAYS * 86400000;
|
|
const posts = sched.listPosts({ client: slug })
|
|
.filter((p) => p.clientReview && (p.clientReview.status === "pending" || new Date(p.clientReview.decidedAt || p.clientReview.requestedAt).getTime() >= since))
|
|
.filter((p) => p.status !== "cancelled")
|
|
.map((p) => ({
|
|
id: p.id, type: p.type, platforms: p.platforms, localTime: p.localTime, timezone: p.timezone, status: p.status,
|
|
caption: p.caption || "", captions: Object.fromEntries(Object.entries(p.options || {}).filter(([, o]) => o?.caption).map(([k, o]) => [k, o.caption])),
|
|
fbCaption: p.fbCaption || null, title: p.options?.youtube?.title || null,
|
|
media: (p.media || []).map((m, i) => ({ url: `media/${p.id}/${i}`, video: /\.(mp4|mov|m4v|webm)$/i.test(m) })),
|
|
review: { status: p.clientReview.status, note: p.clientReview.note || "", by: p.clientReview.by || null, decidedAt: p.clientReview.decidedAt || null },
|
|
}));
|
|
return { client: c?.name || slug, agency: agencyName(), logo: Boolean(logoPathFn()), posts };
|
|
}
|
|
|
|
/** A media file of one of this client's posts that is in review (no other files are reachable). */
|
|
export function mediaFile(slug, postId, index) {
|
|
const p = sched.listPosts({ client: slug }).find((x) => x.id === postId && x.clientReview);
|
|
const f = p?.media?.[Number(index)];
|
|
if (!f || /^https?:/i.test(f) || !fs.existsSync(f)) return null;
|
|
return f;
|
|
}
|