From 92ee5c28b297138478e132ec0b5a46de6e043ff6 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 23 Oct 2026 10:03:23 -0400 Subject: [PATCH] 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 --- connector/src/schedule.js | 23 ++++ dashboard/server/index.js | 29 +++++ dashboard/server/portal.js | 82 ++++++++++++++ dashboard/server/portal/index.html | 135 +++++++++++++++++++++++ dashboard/web/src/pages/Client.jsx | 3 + dashboard/web/src/pages/ClientPortal.jsx | 66 +++++++++++ dashboard/web/src/pages/Publishing.jsx | 7 +- 7 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 dashboard/server/portal.js create mode 100644 dashboard/server/portal/index.html create mode 100644 dashboard/web/src/pages/ClientPortal.jsx diff --git a/connector/src/schedule.js b/connector/src/schedule.js index bf88131..2782692 100644 --- a/connector/src/schedule.js +++ b/connector/src/schedule.js @@ -147,6 +147,29 @@ export function updatePost(id, patch, by = "team") { if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); } p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString(); p.warnings = quotaWarnings(p, items); + // Changed after the client looked at it: ask them again. + if (p.clientReview && p.clientReview.status !== "pending") p.clientReview = { ...p.clientReview, status: "pending", previous: p.clientReview.status, requestedAt: p.updatedAt, note: "", by: null }; + write(items); return p; +} + +/** Put a draft in front of the client (their approval link shows it). The admin's own approval is still needed to publish. */ +export function requestClientReview(id, by = "team") { + const items = read(); const p = items.find((x) => x.id === id); + if (!p) throw err("Post not found.", 404); + if (p.status !== "draft") throw err("Only drafts can be sent to the client."); + p.clientReview = { status: "pending", requestedBy: by, requestedAt: new Date().toISOString(), note: "", by: null, decidedAt: null }; + write(items); return p; +} + +/** The client's answer from their approval link: approved, or changes with a note. */ +export function clientDecision(id, client, { decision, note = "", name = "" }) { + if (!["approved", "changes"].includes(decision)) throw err("Choose approve or request changes."); + if (decision === "changes" && !String(note).trim()) throw err("Tell us what to change."); + const items = read(); const p = items.find((x) => x.id === id && x.client === client); + if (!p || !p.clientReview) throw err("Post not found.", 404); + if (p.status !== "draft") throw err("This post has already moved on; ask your agency if something needs changing."); + p.clientReview = { ...p.clientReview, status: decision, note: String(note).slice(0, 2000), by: String(name).slice(0, 80) || "Client", decidedAt: new Date().toISOString() }; + log({ action: `client_${decision}`, post: id, client, by: p.clientReview.by }); write(items); return p; } diff --git a/dashboard/server/index.js b/dashboard/server/index.js index f8ac4f3..b34054b 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -9,6 +9,7 @@ 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 odoo from "./odoo.js"; import * as jobs from "./jobs.js"; import * as docs from "./docs.js"; @@ -62,6 +63,25 @@ app.get("/privacy", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "privacy.ht 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 }); } +}); + // 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); @@ -283,6 +303,15 @@ app.post("/api/schedule", h(async (req) => ({ post: sched.createPost({ ...req.bo 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 || "")); diff --git a/dashboard/server/portal.js b/dashboard/server/portal.js new file mode 100644 index 0000000..02ef7b0 --- /dev/null +++ b/dashboard/server/portal.js @@ -0,0 +1,82 @@ +// 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; +} diff --git a/dashboard/server/portal/index.html b/dashboard/server/portal/index.html new file mode 100644 index 0000000..13a13c9 --- /dev/null +++ b/dashboard/server/portal/index.html @@ -0,0 +1,135 @@ + + + + + + + +Posts for your approval + + + +
+
+

Posts for your approval

+

Loading…

+
+
+ + +
+ +
+ +
+
+
+ + + diff --git a/dashboard/web/src/pages/Client.jsx b/dashboard/web/src/pages/Client.jsx index 50787eb..c058180 100644 --- a/dashboard/web/src/pages/Client.jsx +++ b/dashboard/web/src/pages/Client.jsx @@ -3,6 +3,7 @@ import { Link, useNavigate, useParams } from "react-router-dom"; import { Bar, BarChart, CartesianGrid, Cell, Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, FileDown, FileText, Eye, RefreshCw, Sparkles, Briefcase, Link2, Facebook, Instagram } from "lucide-react"; import { api } from "../api.js"; +import ClientPortal from "./ClientPortal.jsx"; import { useAuth } from "../App.jsx"; import { Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo, @@ -97,6 +98,7 @@ export default function Client() { { value: "performance", label: "Performance" }, { value: "docs", label: "Documents", count: data.docs.filter((d) => d.kind !== "setup").length }, { value: "runs", label: "AI runs", count: data.jobs.length }, + { value: "portal", label: "Client approval" }, ]} /> @@ -112,6 +114,7 @@ export default function Client() { ))} {tab === "docs" && } {tab === "runs" && } + {tab === "portal" && } setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} /> {linkOpen && setLinkOpen(false)} onLinked={() => { setLinkOpen(false); load(); }} />} diff --git a/dashboard/web/src/pages/ClientPortal.jsx b/dashboard/web/src/pages/ClientPortal.jsx new file mode 100644 index 0000000..d6dc81a --- /dev/null +++ b/dashboard/web/src/pages/ClientPortal.jsx @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Check, Copy, ExternalLink, Link2, RefreshCw, Unlink } from "lucide-react"; +import { api } from "../api.js"; +import { useAuth } from "../App.jsx"; +import { Button, Empty, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx"; + +/** Client page tab: the client's private approval link and who to notify. */ +export default function ClientPortal({ slug, clientName }) { + const { isAdmin } = useAuth(); + const [link, setLink] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [copied, setCopied] = useState(false); + const [contact, setContact] = useState({ contactName: "", contactEmail: "" }); + + const load = useCallback(() => api(`/api/portal/${slug}`).then((l) => { setLink(l); setContact({ contactName: l.contactName || "", contactEmail: l.contactEmail || "" }); }).catch(setError), [slug]); + useEffect(() => { load(); }, [load]); + const run = async (what, fn) => { setBusy(what); setError(null); try { const l = await fn(); if (l) { setLink(l); } } catch (e) { setError(e); } setBusy(null); }; + + if (!link) return error ? : ; + const url = link.enabled ? `${location.origin}/p/${link.token}` : null; + const copy = () => navigator.clipboard?.writeText(url).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); + + return ( +
+ + {!link.enabled ? ( + + run("issue", () => api(`/api/portal/${slug}`, { method: "POST", body: {} }))}>Create approval link : An admin creates the link.}> + Give {clientName} a private link where they see the posts you send them, approve them or ask for changes. No login needed. Your team still gives the final approval before anything is posted. + + + ) : ( + <> + +
+
+ {url} + + +
+

+ To send a post for review: Publishing → a draft → Send to client. Their answer shows on the draft. +

+ {isAdmin && ( +
+ + +
+ )} +
+
+ +
{ e.preventDefault(); run("contact", () => api(`/api/portal/${slug}`, { method: "PATCH", body: contact })); }}> + setContact({ ...contact, contactName: e.target.value })} /> + setContact({ ...contact, contactEmail: e.target.value })} /> + {isAdmin && } +
+
+ + )} +
+ ); +} diff --git a/dashboard/web/src/pages/Publishing.jsx b/dashboard/web/src/pages/Publishing.jsx index c40b699..2a4c5c6 100644 --- a/dashboard/web/src/pages/Publishing.jsx +++ b/dashboard/web/src/pages/Publishing.jsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { CalendarClock, Check, Image as ImageIcon, Plus, RotateCcw, Upload, X } from "lucide-react"; +import { CalendarClock, Check, Image as ImageIcon, Plus, RotateCcw, Upload, X, Send } from "lucide-react"; import { api } from "../api.js"; import { useAuth } from "../App.jsx"; import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner } from "../ui.jsx"; import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx"; +const REVIEW = { pending: { tone: "warn", label: "With client" }, approved: { tone: "ok", label: "Client approved" }, changes: { tone: "bad", label: "Client wants changes" } }; const STATUS = { draft: "neutral", approved: "brand", publishing: "brand", published: "ok", partial: "warn", failed: "bad", cancelled: "neutral" }; const TYPES = [["IMAGE", "Single image"], ["REELS", "Reel / video"], ["CAROUSEL", "Carousel / multi-image"], ["STORY", "Story (Instagram, Facebook)"], ["TEXT", "Text only (no Instagram)"]]; @@ -69,6 +70,7 @@ export default function Publishing() {
{p.clientName} {p.status} + {p.clientReview && p.status === "draft" && {REVIEW[p.clientReview.status].label}} {p.type.toLowerCase()} · {p.platforms.join(" + ")}
{p.localTime} ({p.timezone.split("/").pop().replace("_", " ")} time)
@@ -76,6 +78,8 @@ export default function Publishing() { {p.collaborators?.length > 0 &&
Collab: {p.collaborators.map((c) => "@" + c).join(", ")}
} {Object.entries(p.errors || {}).filter(([k]) => !p.waiting?.[k]).map(([k, v]) =>
{k}: {v}
)} {Object.entries(p.waiting || {}).map(([k, until]) =>
{k}: daily limit reached. Goes out automatically after {new Date(until).toLocaleString("en-CA", { weekday: "short", hour: "numeric", minute: "2-digit" })}.
)} + {p.clientReview?.status === "changes" && p.status === "draft" &&
{p.clientReview.by || "Client"}: {p.clientReview.note}
} + {p.clientReview?.status === "approved" && p.status === "draft" &&
Client approved{p.clientReview.by ? ` (${p.clientReview.by})` : ""}. Ready for your final approval.
} {p.status === "draft" && (p.warnings || []).map((w) =>
{w}
)} {Object.entries(p.results || {}).filter(([, v]) => v?.permalink).map(([k, v]) => View on {k})} {p.results?.tiktok?.inbox &&
TikTok: sent to the account's TikTok inbox. Finish and post it in the TikTok app.
} @@ -83,6 +87,7 @@ export default function Publishing() {
by {p.createdBy}{p.approvedBy ? ` · approved by ${p.approvedBy}` : ""}
+ {p.status === "draft" && p.clientReview?.status !== "pending" && } {p.status === "draft" && isAdmin && } {["failed", "partial"].includes(p.status) && isAdmin && } {!["published", "cancelled", "publishing"].includes(p.status) && }