// Client folders under clients// are the source of truth. // clients//client.json links a folder to its Facebook Page / Instagram account. import fs from "node:fs"; import path from "node:path"; import { CLIENTS_DIR, TEMPLATE_DIR } from "./config.js"; import * as meta from "../../connector/src/meta.js"; const SKIP = new Set(["_template"]); const DOC_DIRS = { reports: "reports", calendar: "calendar", trends: "trends" }; export const slugify = (s) => String(s).toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").replace(/&/g, "and").trim().replace(/[\s_]+/g, "-").replace(/-+/g, "-"); export function safeSlug(slug) { if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || SKIP.has(slug)) throw Object.assign(new Error("Unknown client."), { status: 404 }); const dir = path.join(CLIENTS_DIR, slug); if (!fs.existsSync(dir)) throw Object.assign(new Error("Unknown client."), { status: 404 }); return dir; } function readJson(file, fallback = null) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return fallback; } } function titleFromBrand(dir) { try { const first = fs.readFileSync(path.join(dir, "brand.md"), "utf8").split(/\r?\n/)[0]; return first.replace(/^#\s*/, "").replace(/\s+—\s+Brand.*$/i, "").trim(); } catch { return null; } } /** How complete the client's setup files are (template placeholders count as empty). */ function setupStatus(dir) { const check = (name) => { try { const text = fs.readFileSync(path.join(dir, name), "utf8"); const placeholders = (text.match(/…|\(e\.g\.|name, WhatsApp|^\s*-\s*[^:\n]+:\s*$/gm) || []).length; return { exists: true, draft: /DRAFT/.test(text), placeholders }; } catch { return { exists: false, draft: false, placeholders: 0 }; } }; return { brand: check("brand.md"), goals: check("goals.md"), constraints: check("constraints.md") }; } /** Every document the team or AI produced for this client, newest first. */ export function listDocs(slug) { const dir = safeSlug(slug); const docs = []; const add = (rel, kind) => { const full = path.join(dir, rel); const stat = fs.statSync(full); const pdf = full.replace(/\.md$/, ".pdf"); docs.push({ path: rel.replace(/\\/g, "/"), kind, audience: /-internal\.md$/.test(rel) ? "internal" : /-client\.md$/.test(rel) ? "client" : "team", title: path.basename(rel, ".md"), updatedAt: stat.mtime.toISOString(), pdf: fs.existsSync(pdf) ? { updatedAt: fs.statSync(pdf).mtime.toISOString(), stale: fs.statSync(pdf).mtime < stat.mtime } : null, }); }; for (const f of fs.readdirSync(dir)) { if (/^audit-.*\.md$/.test(f)) add(f, "audit"); else if (/^profile-check-.*\.md$/.test(f)) add(f, "profile-check"); else if (/^competitors-.*\.md$/.test(f)) add(f, "competitors"); } for (const [kind, sub] of Object.entries(DOC_DIRS)) { const d = path.join(dir, sub); if (!fs.existsSync(d)) continue; for (const f of fs.readdirSync(d)) if (f.endsWith(".md")) add(path.join(sub, f), kind === "reports" ? (/-monthly\.md$/.test(f) ? "monthly" : "report") : kind); } for (const f of ["brand.md", "goals.md", "constraints.md", "history.md"]) if (fs.existsSync(path.join(dir, f))) add(f, "setup"); return docs.sort((a, b) => (a.kind === "setup") - (b.kind === "setup") || b.updatedAt.localeCompare(a.updatedAt)); } export function listClients() { return fs .readdirSync(CLIENTS_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory() && !SKIP.has(d.name)) .map((d) => { const dir = path.join(CLIENTS_DIR, d.name); const link = readJson(path.join(dir, "client.json"), {}); const docs = listDocs(d.name); const lastAudit = docs.find((x) => x.kind === "audit"); const lastReport = docs.find((x) => x.kind === "report"); return { slug: d.name, name: link.name || titleFromBrand(dir) || d.name, pageId: link.pageId || null, igId: link.igId || null, igUsername: link.igUsername || null, category: link.category || null, odooProject: link.odooProject || null, linked: Boolean(link.pageId || link.igId), igOnly: Boolean(!link.pageId && link.igId), setup: setupStatus(dir), lastAuditAt: lastAudit?.updatedAt || null, lastReportAt: lastReport?.updatedAt || null, docCount: docs.filter((x) => x.kind !== "setup").length, }; }) .sort((a, b) => a.name.localeCompare(b.name)); } export function getClient(slug) { safeSlug(slug); return listClients().find((c) => c.slug === slug); } export function saveLink(slug, patch) { const file = path.join(safeSlug(slug), "client.json"); const next = { ...readJson(file, {}), ...patch }; fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n"); return next; } /** Meta accounts the agency can manage that no client folder is linked to yet. */ export async function unlinkedAccounts({ refresh = false } = {}) { const accounts = await meta.listAccounts({ refresh }); const linked = new Set(listClients().flatMap((c) => [c.pageId, c.igId && `ig:${c.igId}`]).filter(Boolean)); return accounts .filter((a) => !linked.has(meta.accountKey(a)) && !(a.instagram?.id && linked.has(`ig:${a.instagram.id}`))) .map((a) => ({ ...meta.publicAccount(a), account_id: meta.accountKey(a) })); } /** Link an existing client folder to a Meta Page the agency manages (keeps the folder's name and files). */ export async function linkToAccount(slug, accountId) { const client = getClient(slug); if (!client) throw Object.assign(new Error("Client not found."), { status: 404 }); const account = await findConnected(accountId); const other = clientUsing(account, slug); if (other) throw Object.assign(new Error(`That account is already linked to ${other.name}.`), { status: 409 }); return saveLink(slug, { ...linkFields(account), name: client.name || account.page_name }); } /** Best-effort auto link for folders created before client.json existed. */ export async function autoLink() { const accounts = await meta.listAccounts(); const results = []; for (const c of listClients()) { if (c.linked) continue; const s = c.slug.replace(/-/g, ""); const match = accounts.find((a) => slugify(a.page_name) === c.slug || a.instagram?.username?.replace(/[_.]/g, "-") === c.slug) || accounts.find((a) => slugify(a.page_name).replace(/-/g, "") === s || a.instagram?.username?.replace(/[_.]/g, "") === s) || accounts.find((a) => slugify(a.page_name).replace(/-/g, "").startsWith(s) || s.startsWith(slugify(a.page_name).replace(/-/g, ""))); if (match) { saveLink(c.slug, linkFields(match)); results.push({ slug: c.slug, page: match.page_name, ig: match.instagram?.username || null }); } else results.push({ slug: c.slug, page: null }); } return results; } /** A connected account by its id: a Page id, or "ig:" for Instagram-only accounts. */ async function findConnected(accountId) { const id = String(accountId || ""); const account = (await meta.listAccounts({ refresh: true })).find((a) => meta.accountKey(a) === id); if (!account) throw Object.assign(new Error("That account isn't connected to the agency's Meta account."), { status: 400 }); return account; } /** Another client already linked to this account (same Page, or same Instagram account). */ const clientUsing = (account, exceptSlug) => listClients().find((c) => c.slug !== exceptSlug && ((account.page_id && c.pageId === account.page_id) || (account.instagram?.id && c.igId === account.instagram.id))); const linkFields = (a) => ({ name: a.page_name, pageId: a.page_id, igId: a.instagram?.id || null, igUsername: a.instagram?.username || null, category: a.category || null, linkedAt: new Date().toISOString(), }); /** "Add business": create clients// from the template for a connected Meta account. */ export async function addClientFromAccount(accountId, { name } = {}) { const account = await findConnected(accountId); if (clientUsing(account)) throw Object.assign(new Error("This business already has a client folder."), { status: 409 }); const displayName = (name || account.page_name).trim(); const idTail = (account.page_id || account.instagram.id).slice(-4); let slug = slugify(displayName) || `client-${idTail}`; if (fs.existsSync(path.join(CLIENTS_DIR, slug))) slug = `${slug}-${idTail}`; const dir = path.join(CLIENTS_DIR, slug); fs.cpSync(TEMPLATE_DIR, dir, { recursive: true }); for (const sub of Object.values(DOC_DIRS)) fs.mkdirSync(path.join(dir, sub), { recursive: true }); // Fill the client name and handles into the template files. for (const f of fs.readdirSync(dir)) { if (!f.endsWith(".md")) continue; const file = path.join(dir, f); let text = fs.readFileSync(file, "utf8").replace(/^#\s*.*?(—|-)\s*/m, `# ${displayName} — `); if (f === "brand.md") { const ig = account.instagram?.username ? `Instagram @${account.instagram.username}` : "Instagram …"; text = text.replace(/^\*\*Handles:\*\*.*$/m, `**Handles:** ${ig} · Facebook: ${account.page_id ? `${account.page_name} (page ${account.page_id})` : "none (Instagram only)"} · YouTube … · LinkedIn …`); if (account.category) text = text.replace(/^\*\*Business type:\*\*.*$/m, `**Business type:** ${account.category}`); } fs.writeFileSync(file, text); } saveLink(slug, { ...linkFields(account), name: displayName }); return getClient(slug); }