metatroncubeswdev 92ee5c28b2 Client approval portal: a private link per client to approve posts or ask for changes
- Admin creates/replaces/turns off the link and sets the reviewer on the client page (Client approval tab)
- Drafts get 'Send to client'; the client's answer (approved / changes + note + name) shows on the draft
- Editing a reviewed draft asks the client again; the admin's approval is still required to publish
- Client page is mobile-first, no login, noindex, media served only for posts in review

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:03:23 -04:00

83 lines
4.0 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, 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;
}