// 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, AGENCY_NAME } from "./config.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: AGENCY_NAME, 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; }