- Refresh on a client with no Facebook Page now returns a clear 400 error instead of silently replying "started" - Client page shows "Not linked to a Facebook Page" with a Link a Page dialog (admins) and shows refresh errors - New POST /api/clients/:slug/link-page links an existing client folder to an unlinked Meta Page, keeping its name and files Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
8.6 KiB
JavaScript
186 lines
8.6 KiB
JavaScript
// Client folders under clients/<slug>/ are the source of truth.
|
|
// clients/<slug>/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" };
|
|
|
|
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");
|
|
}
|
|
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" ? "report" : "calendar");
|
|
}
|
|
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),
|
|
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().map((c) => c.pageId).filter(Boolean));
|
|
return accounts.filter((a) => !linked.has(a.page_id)).map(meta.publicAccount);
|
|
}
|
|
|
|
/** Link an existing client folder to a Meta Page the agency manages (keeps the folder's name and files). */
|
|
export async function linkToAccount(slug, pageId) {
|
|
const client = getClient(slug);
|
|
if (!client) throw Object.assign(new Error("Client not found."), { status: 404 });
|
|
const account = (await meta.listAccounts({ refresh: true })).find((a) => a.page_id === String(pageId));
|
|
if (!account) throw Object.assign(new Error("That Page isn't connected to the agency's Meta account."), { status: 400 });
|
|
const other = listClients().find((c) => c.pageId === account.page_id && c.slug !== slug);
|
|
if (other) throw Object.assign(new Error(`That Page 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;
|
|
}
|
|
|
|
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/<slug>/ from the template for a connected Meta account. */
|
|
export async function addClientFromAccount(pageId, { name } = {}) {
|
|
const account = (await meta.listAccounts({ refresh: true })).find((a) => a.page_id === String(pageId));
|
|
if (!account) throw Object.assign(new Error("That Page isn't connected to the agency's Meta account."), { status: 400 });
|
|
if (listClients().some((c) => c.pageId === account.page_id)) throw Object.assign(new Error("This business already has a client folder."), { status: 409 });
|
|
const displayName = (name || account.page_name).trim();
|
|
let slug = slugify(displayName) || `client-${account.page_id}`;
|
|
if (fs.existsSync(path.join(CLIENTS_DIR, slug))) slug = `${slug}-${account.page_id.slice(-4)}`;
|
|
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_name} (page ${account.page_id}) · 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);
|
|
}
|