// Brand kit per client: saved hashtag sets, approved phrases/CTAs, words to avoid. // checkCaption() flags problems before a post goes for approval (shared by the dashboard, the queue and the MCP tools). import fs from "node:fs"; import path from "node:path"; import { PROJECT_ROOT } from "./env.js"; const FILE = process.env.QUORLA_BRANDKIT_FILE || path.join(PROJECT_ROOT, "data", "brandkit.json"); const CLIENTS = path.join(PROJECT_ROOT, "clients"); fs.mkdirSync(path.dirname(FILE), { recursive: true }); const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } }; const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); // Phrases that read as AI-written or generic (project writing rules). const GENERIC = ["elevate your", "unlock", "in today's fast-paced world", "look no further", "game-changer", "game changer", "take it to the next level", "dive into", "seamless", "revolutionize", "embark on"]; // White-label: the agency must never appear in a client's public post. const AGENCY_WORDS = ["metatroncube", "metatron cube", "quorla"]; const LIMITS = { instagram: 2200, facebook: 63206, tiktok: 2200, youtube: 5000, gbp: 1500, linkedin: 3000, threads: 500, pinterest: 500, reddit: 40000 }; function checkClient(slug) { if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || !fs.existsSync(path.join(CLIENTS, slug))) throw err("Unknown client.", 404); return slug; } const tagList = (v) => [...new Set(String(Array.isArray(v) ? v.join(" ") : v || "").split(/[\s,]+/).map((t) => t.trim()).filter(Boolean).map((t) => `#${t.replace(/^#+/, "")}`).filter((t) => /^#[\p{L}\p{N}_]{1,60}$/u.test(t)))]; const lines = (v) => (Array.isArray(v) ? v : String(v || "").split(/\n/)).map((x) => String(x).trim()).filter(Boolean).slice(0, 200); export function get(client) { checkClient(client); const k = read()[client] || {}; return { hashtagSets: k.hashtagSets || [], phrases: k.phrases || [], avoid: k.avoid || [], notes: k.notes || "", updatedAt: k.updatedAt || null, updatedBy: k.updatedBy || null }; } export function save(client, patch, by = "team") { checkClient(client); const db = read(); const cur = get(client); const next = { hashtagSets: patch.hashtagSets !== undefined ? (patch.hashtagSets || []).slice(0, 30).map((s) => ({ name: String(s.name || "").trim().slice(0, 60) || "Set", tags: tagList(s.tags).slice(0, 30) })).filter((s) => s.tags.length) : cur.hashtagSets, phrases: patch.phrases !== undefined ? lines(patch.phrases).map((x) => x.slice(0, 300)) : cur.phrases, avoid: patch.avoid !== undefined ? lines(patch.avoid).map((x) => x.slice(0, 80)) : cur.avoid, notes: patch.notes !== undefined ? String(patch.notes || "").slice(0, 3000) : cur.notes, updatedAt: new Date().toISOString(), updatedBy: by, }; db[client] = next; const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE); return next; } const EMOJI = /\p{Extended_Pictographic}/gu; /** * Problems in a caption for this client: { level: "bad" | "warn", text }. * bad = must fix (agency named in a client post, over the platform limit); warn = worth a second look. */ export function checkCaption(client, caption, { platforms = [] } = {}) { const text = String(caption || ""); const low = text.toLowerCase(); const out = []; let kit = { avoid: [] }; try { kit = get(client); } catch { /* unknown client: generic checks only */ } // The agency's own account (its client name contains the agency name) may of course mention itself. let ownName = ""; try { ownName = JSON.parse(fs.readFileSync(path.join(CLIENTS, client, "client.json"), "utf8")).name || ""; } catch { /* no client.json */ } const isAgencyItself = AGENCY_WORDS.some((w) => `${ownName} ${client}`.toLowerCase().replace(/[-_]/g, " ").includes(w)); if (!isAgencyItself) for (const w of AGENCY_WORDS) if (low.includes(w)) out.push({ level: "bad", text: `Mentions “${w}”. Client posts must never name the agency.` }); for (const p of platforms) if (LIMITS[p] && text.length > LIMITS[p]) out.push({ level: "bad", text: `Too long for ${p}: ${text.length} of ${LIMITS[p]} characters.` }); for (const w of kit.avoid) if (w && low.includes(w.toLowerCase())) out.push({ level: "warn", text: `Uses “${w}”, which is on this client's avoid list.` }); for (const g of GENERIC) if (low.includes(g)) out.push({ level: "warn", text: `“${g}” sounds generic. Say it the way the client would.` }); const tags = text.match(/#[\p{L}\p{N}_]+/gu) || []; if (tags.length > 10) out.push({ level: "warn", text: `${tags.length} hashtags. 3–8 relevant ones usually do better.` }); if ((text.match(EMOJI) || []).length > 8) out.push({ level: "warn", text: "Lots of emoji. Keep a few that add meaning." }); return out; }