From 7e780549eabace6c47f2efb8c634a7e067c97c9e Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 23 Oct 2026 10:19:04 -0400 Subject: [PATCH] Brand kit and caption checks per client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Client page → Brand kit: hashtag sets, approved phrases/CTAs, words to avoid, voice notes - New post: one-click insert of hashtag sets and phrases, live checks while typing - Checks: agency named in a client post (refused when saving; the agency's own account is exempt), avoid list, generic AI-sounding phrases, too many hashtags or emoji, platform length limits - Brand warnings also appear on drafts; MCP tools brand_kit and caption_check for the AI Co-Authored-By: Claude Opus 5.5 --- connector/src/brandkit.js | 71 ++++++++++++++++++++ connector/src/schedule.js | 18 ++++- connector/src/server.js | 8 +++ connector/test/brandkit.test.mjs | 24 +++++++ dashboard/server/index.js | 6 ++ dashboard/web/src/pages/BrandKit.jsx | 91 ++++++++++++++++++++++++++ dashboard/web/src/pages/Client.jsx | 3 + dashboard/web/src/pages/Publishing.jsx | 2 + 8 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 connector/src/brandkit.js create mode 100644 connector/test/brandkit.test.mjs create mode 100644 dashboard/web/src/pages/BrandKit.jsx diff --git a/connector/src/brandkit.js b/connector/src/brandkit.js new file mode 100644 index 0000000..2745ec5 --- /dev/null +++ b/connector/src/brandkit.js @@ -0,0 +1,71 @@ +// 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; +} diff --git a/connector/src/schedule.js b/connector/src/schedule.js index c0b551e..2a35c03 100644 --- a/connector/src/schedule.js +++ b/connector/src/schedule.js @@ -9,6 +9,7 @@ import { PROJECT_ROOT } from "./env.js"; import * as meta from "./meta.js"; import * as extra from "./platforms/index.js"; import * as quota from "./platforms/quota.js"; +import * as brandkit from "./brandkit.js"; const META_PLATFORMS = ["instagram", "facebook"]; export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS]; @@ -90,6 +91,19 @@ function quotaAccount(client, platform) { } /** Warnings (not errors) when a day's planned posts exceed a platform's limit; extra posts then wait for the reset. */ +/** Brand-kit and writing checks on every caption a post will use. Naming the agency is refused outright. */ +function brandWarnings(post) { + const captions = [[null, post.caption], ["facebook", post.fbCaption], ...Object.entries(post.options || {}).map(([pl, o]) => [pl, o?.caption])].filter(([, c]) => c); + const out = []; + for (const [pl, c] of captions) { + for (const w of brandkit.checkCaption(post.client, c, { platforms: pl ? [pl] : post.platforms })) { + if (/never name the agency/.test(w.text)) throw err(`${pl ? `${pl} caption: ` : ""}${w.text}`); + out.push(`${pl ? `${pl} caption: ` : ""}${w.text}`); + } + } + return [...new Set(out)]; +} + function quotaWarnings(post, items) { const at = new Date(post.publishAt).getTime(); const out = []; @@ -137,7 +151,7 @@ export function createPost({ client, platforms = ["instagram"], type = "IMAGE", results: {}, errors: {}, }; const items = read(); - post.warnings = quotaWarnings(post, items); + post.warnings = [...quotaWarnings(post, items), ...brandWarnings(post)]; items.push(post); write(items); emit("draft_created", post); return post; @@ -151,7 +165,7 @@ export function updatePost(id, patch, by = "team") { for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p); 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); + p.warnings = [...quotaWarnings(p, items), ...brandWarnings(p)]; // 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; diff --git a/connector/src/server.js b/connector/src/server.js index 6efb982..82053c7 100644 --- a/connector/src/server.js +++ b/connector/src/server.js @@ -12,6 +12,7 @@ import * as sched from "./schedule.js"; import * as lib from "./library.js"; import * as plan from "./plan.js"; import * as competitors from "./competitors.js"; +import * as brandkit from "./brandkit.js"; import * as extra from "./platforms/index.js"; const server = new McpServer({ name: "social-manager", version: "0.1.0" }); @@ -214,4 +215,11 @@ server.tool("competitor_add", "Start tracking an Instagram competitor for a clie { client: z.string(), username: z.string(), note: z.string().default("") }, wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note }))); +server.tool("brand_kit", "A client's saved hashtag sets, approved phrases/CTAs and words to avoid. Read it before writing captions for that client.", + { client: z.string() }, wrap(async ({ client }) => brandkit.get(client))); + +server.tool("caption_check", "Check a caption against the client's brand kit and our writing rules before scheduling (agency name, avoid list, generic phrases, hashtag count, platform length).", + { client: z.string(), caption: z.string(), platforms: z.array(z.string()).default([]) }, + wrap(async ({ client, caption, platforms }) => brandkit.checkCaption(client, caption, { platforms }))); + await server.connect(new StdioServerTransport()); diff --git a/connector/test/brandkit.test.mjs b/connector/test/brandkit.test.mjs new file mode 100644 index 0000000..ee24266 --- /dev/null +++ b/connector/test/brandkit.test.mjs @@ -0,0 +1,24 @@ +// Brand kit and caption checks (offline, temp file). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +process.env.QUORLA_BRANDKIT_FILE = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "quorla-bk-")), "bk.json"); +const bk = await import("../src/brandkit.js"); +const { PROJECT_ROOT } = await import("../src/env.js"); +const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && !/metatron/i.test(d) && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json"))); + +test("brand kit: saves clean hashtag sets and lists; caption checks", { skip: !client && "no client folder" }, () => { + const k = bk.save(client, { hashtagSets: [{ name: "Local", tags: "waterloo, #kitchener #KW bad-tag!" }], phrases: "Order online\n\nBook a table", avoid: "cheap\nbest in town" }); + assert.deepEqual(k.hashtagSets[0].tags, ["#waterloo", "#kitchener", "#KW"]); + assert.deepEqual(k.phrases, ["Order online", "Book a table"]); + const w = bk.checkCaption(client, "The best in town, made by Quorla. Unlock flavour! " + "#a ".repeat(12), { platforms: ["threads"] }).map((x) => x.level + ":" + x.text.slice(0, 25)); + assert.ok(w.some((x) => x.startsWith("bad:Mentions “quorla”"))); + assert.ok(w.some((x) => x.startsWith("warn:Uses “best in town”"))); + assert.ok(w.some((x) => x.startsWith("warn:“unlock”"))); + assert.ok(w.some((x) => x.startsWith("warn:12 hashtags"))); + assert.equal(bk.checkCaption(client, "x".repeat(600), { platforms: ["threads"] })[0].level, "bad"); + assert.deepEqual(bk.checkCaption(client, "Fresh focaccia at 8. First 20 people get a free cortado.", { platforms: ["instagram"] }), []); +}); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index c5e577e..24e2c2c 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -21,6 +21,7 @@ import * as sched from "../../connector/src/schedule.js"; import * as lib from "../../connector/src/library.js"; import * as plan from "../../connector/src/plan.js"; import * as competitors from "../../connector/src/competitors.js"; +import * as brandkit from "../../connector/src/brandkit.js"; import * as extra from "../../connector/src/platforms/index.js"; import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js"; import { DATA_DIR as _DD } from "./config.js"; @@ -333,6 +334,11 @@ app.put("/api/clients/:slug/monthly", requireAdmin, h(async (req) => ({ settings app.post("/api/clients/:slug/monthly/build", h(async (req) => monthly.buildWithPdf(req.params.slug, String(req.body?.month || monthly.lastMonth())))); app.post("/api/clients/:slug/monthly/email", requireAdmin, h(async (req) => monthly.emailReport(req.params.slug, String(req.body?.month || monthly.lastMonth())))); +// ---------- brand kit: hashtag sets, phrases, words to avoid; caption checks ---------- +app.get("/api/brandkit/:client", h(async (req) => brandkit.get(req.params.client))); +app.put("/api/brandkit/:client", h(async (req) => brandkit.save(req.params.client, req.body || {}, who(req)))); +app.post("/api/brandkit/:client/check", h(async (req) => ({ warnings: brandkit.checkCaption(req.params.client, req.body?.caption, { platforms: req.body?.platforms || [] }) }))); + // ---------- notifications ---------- app.get("/api/notifications", h(async (req) => notify.listFor(req.user.id))); app.post("/api/notifications/read", h(async (req) => { notify.markRead(req.user.id, Array.isArray(req.body?.ids) ? req.body.ids : null); return notify.listFor(req.user.id); })); diff --git a/dashboard/web/src/pages/BrandKit.jsx b/dashboard/web/src/pages/BrandKit.jsx new file mode 100644 index 0000000..44d6070 --- /dev/null +++ b/dashboard/web/src/pages/BrandKit.jsx @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from "react"; +import { Plus, Save, Trash2 } from "lucide-react"; +import { api } from "../api.js"; +import { Button, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx"; + +/** Client page tab: hashtag sets, approved phrases/CTAs, words to avoid, voice notes. */ +export default function BrandKit({ slug }) { + const [kit, setKit] = useState(null); + const [sets, setSets] = useState([]); + const [phrases, setPhrases] = useState(""); + const [avoid, setAvoid] = useState(""); + const [notes, setNotes] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + const apply = (k) => { setKit(k); setSets(k.hashtagSets.map((s) => ({ name: s.name, tags: s.tags.join(" ") }))); setPhrases(k.phrases.join("\n")); setAvoid(k.avoid.join("\n")); setNotes(k.notes); }; + useEffect(() => { api(`/api/brandkit/${slug}`).then(apply).catch(setError); }, [slug]); + + const save = async () => { + setBusy(true); setError(null); setSaved(false); + try { apply(await api(`/api/brandkit/${slug}`, { method: "PUT", body: { hashtagSets: sets, phrases, avoid, notes } })); setSaved(true); } catch (e) { setError(e); } + setBusy(false); + }; + if (!kit) return error ? : ; + + return ( +
+ +
+ {sets.map((s, i) => ( +
+ + setSets(sets.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))} /> + + setSets(sets.map((x, j) => (j === i ? { ...x, tags: e.target.value } : x)))} /> + +
+ ))} + +
+
+
+ + +