Posts for your approval
+Loading…
+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 @@ + + +
+ + + + +Loading…
+