Support Instagram-only clients (no Facebook Page)

- Connector lists Instagram accounts assigned to the system user without a
  Page; numbers, inbox, mentions and publishing work for them
- Clients link by Page or Instagram account ("Link an account"); Facebook
  features explain the client has no Page instead of failing
- Drafts for Instagram-only clients drop Facebook automatically
- Images for Instagram-only clients are hosted via the agency's own Page
  (META_MEDIA_PAGE_ID, unpublished); never falls back to a client's Page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-20 01:02:03 -04:00
parent 7f7e41b617
commit 3cbc76e7f1
8 changed files with 121 additions and 52 deletions

View File

@ -4,6 +4,10 @@
META_SYSTEM_USER_TOKEN=PASTE_TOKEN_HERE META_SYSTEM_USER_TOKEN=PASTE_TOKEN_HERE
META_API_VERSION=v23.0 META_API_VERSION=v23.0
# Your agency's OWN Facebook Page ID. Images for Instagram-only clients (no Page) are uploaded there
# unpublished to get a link Instagram can fetch. Never use a client's Page here.
META_MEDIA_PAGE_ID=
# Keep false until you are ready. When true, the agent can publish, but only with confirm=true after human approval. # Keep false until you are ready. When true, the agent can publish, but only with confirm=true after human approval.
ALLOW_PUBLISH=false ALLOW_PUBLISH=false

View File

@ -62,10 +62,32 @@ export async function listAccounts({ refresh = false } = {}) {
next = u.pathname.replace(`/${VERSION}/`, ""); next = u.pathname.replace(`/${VERSION}/`, "");
params = Object.fromEntries([...u.searchParams].filter(([k]) => k !== "access_token")); params = Object.fromEntries([...u.searchParams].filter(([k]) => k !== "access_token"));
} }
// Instagram accounts assigned to the system user directly, with no Facebook Page.
try {
const onPages = new Set(out.map((a) => a.instagram?.id).filter(Boolean));
const ig = await graph("me/assigned_instagram_accounts", { fields: "id,username,name,followers_count,media_count", limit: 100 });
for (const i of ig.data || []) {
if (onPages.has(i.id)) continue;
out.push({ page_id: null, page_name: i.name || i.username, category: "Instagram only", tasks: null, page_token: null, instagram: i, ig_only: true });
}
} catch { /* older setups: Pages only */ }
accountCache = out; accountCache = out;
return out; return out;
} }
/** Stable id for a connected account: the Page id, or "ig:<id>" for an Instagram account with no Facebook Page. */
export const accountKey = (a) => a.page_id || `ig:${a.instagram?.id}`;
/** The connected account a client's link (client.json) points at: by Page, or by Instagram id when there's no Page. */
export async function accountForLink({ pageId, igId } = {}) {
const pick = (list) => (pageId ? list.find((a) => a.page_id === pageId) : igId ? list.find((a) => a.instagram?.id === igId) : null);
return pick(await listAccounts()) || pick(await listAccounts({ refresh: true })) || null;
}
function needPage(account) {
if (!account.page_id) throw Object.assign(new Error(`${account.page_name} is Instagram-only (no Facebook Page), so Facebook features aren't available for it.`), { status: 400 });
}
/** Find an account by page id, IG id, page name or IG username (case-insensitive, partial ok). */ /** Find an account by page id, IG id, page name or IG username (case-insensitive, partial ok). */
export async function findAccount(query) { export async function findAccount(query) {
const q = String(query).toLowerCase().replace(/^@/, ""); const q = String(query).toLowerCase().replace(/^@/, "");
@ -168,6 +190,7 @@ export async function igPublish(igId, { mediaUrl, caption, type = "IMAGE" }) {
export const PAGE_METRICS = ["page_post_engagements", "page_views_total", "page_daily_follows_unique", "page_media_view", "page_total_media_view_unique"]; export const PAGE_METRICS = ["page_post_engagements", "page_views_total", "page_daily_follows_unique", "page_media_view", "page_total_media_view_unique"];
export async function pageInsights(account, { sinceDays = 28, metrics = PAGE_METRICS } = {}) { export async function pageInsights(account, { sinceDays = 28, metrics = PAGE_METRICS } = {}) {
needPage(account);
const info = await graph(account.page_id, { fields: "id,name,fan_count,followers_count,link" }, { token: account.page_token }); const info = await graph(account.page_id, { fields: "id,name,fan_count,followers_count,link" }, { token: account.page_token });
const window = { period: "day", since: days(Math.min(sinceDays, 90)), until: days(0) }; const window = { period: "day", since: days(Math.min(sinceDays, 90)), until: days(0) };
const { results, errors } = await metricsOneByOne(account.page_id, metrics, window, account.page_token); const { results, errors } = await metricsOneByOne(account.page_id, metrics, window, account.page_token);
@ -178,6 +201,7 @@ export async function pageInsights(account, { sinceDays = 28, metrics = PAGE_MET
} }
export async function pageRecentPosts(account, { limit = 10 } = {}) { export async function pageRecentPosts(account, { limit = 10 } = {}) {
needPage(account);
const r = await graph( const r = await graph(
`${account.page_id}/posts`, `${account.page_id}/posts`,
{ fields: "id,message,created_time,permalink_url,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit }, { fields: "id,message,created_time,permalink_url,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit },
@ -195,6 +219,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) {
} }
export async function pagePublish(account, { message, imageUrl, link }) { export async function pagePublish(account, { message, imageUrl, link }) {
needPage(account);
if (imageUrl) return graph(`${account.page_id}/photos`, { url: imageUrl, caption: message }, { method: "POST", token: account.page_token }); if (imageUrl) return graph(`${account.page_id}/photos`, { url: imageUrl, caption: message }, { method: "POST", token: account.page_token });
return graph(`${account.page_id}/feed`, { message, link }, { method: "POST", token: account.page_token }); return graph(`${account.page_id}/feed`, { message, link }, { method: "POST", token: account.page_token });
} }
@ -260,7 +285,7 @@ export async function commentInbox(account, { days = 14, posts = 12 } = {}) {
} }
} }
} }
const fbPosts = await pageRecentPosts(account, { limit: posts }).catch(() => []); const fbPosts = account.page_id ? await pageRecentPosts(account, { limit: posts }).catch(() => []) : [];
for (const p of fbPosts.filter((x) => x.comments > 0)) { for (const p of fbPosts.filter((x) => x.comments > 0)) {
const cs = await fbPostComments(account, p.id).catch(() => []); const cs = await fbPostComments(account, p.id).catch(() => []);
for (const c of cs) { for (const c of cs) {
@ -313,24 +338,43 @@ export async function igAudience(igId) {
} }
// ---------- Direct messages (needs instagram_manage_messages / pages_messaging) ---------- // ---------- Direct messages (needs instagram_manage_messages / pages_messaging) ----------
// Instagram-only accounts (no Page) go through the Instagram account itself with the system user token.
export async function listConversations(account, { platform = "instagram", limit = 20 } = {}) { export async function listConversations(account, { platform = "instagram", limit = 20 } = {}) {
const r = await graph(`${account.page_id}/conversations`, { if (!account.page_id && platform !== "instagram") needPage(account);
const node = account.page_id || account.instagram?.id;
const r = await graph(`${node}/conversations`, {
platform, limit, fields: "id,updated_time,participants,messages.limit(3){id,message,from,created_time}", platform, limit, fields: "id,updated_time,participants,messages.limit(3){id,message,from,created_time}",
}, { token: account.page_token }); }, { token: account.page_token || undefined });
return r.data || []; return r.data || [];
} }
export async function sendMessage(account, recipientId, text, { platform = "instagram" } = {}) { export async function sendMessage(account, recipientId, text, { platform = "instagram" } = {}) {
return graph(`${account.page_id}/messages`, { if (!account.page_id && platform !== "instagram") needPage(account);
const node = account.page_id || account.instagram?.id;
return graph(`${node}/messages`, {
recipient: JSON.stringify({ id: recipientId }), message: JSON.stringify({ text }), messaging_type: "RESPONSE", recipient: JSON.stringify({ id: recipientId }), message: JSON.stringify({ text }), messaging_type: "RESPONSE",
...(platform === "instagram" ? { platform: "instagram" } : {}), ...(platform === "instagram" ? { platform: "instagram" } : {}),
}, { method: "POST", token: account.page_token }); }, { method: "POST", token: account.page_token || undefined });
} }
// ---------- Media hosting helpers ---------- // ---------- Media hosting helpers ----------
/** Turn a local image into a public URL by uploading it to the client's Page as an unpublished photo. */ /**
* Turn a local image into a public URL by uploading it as an UNPUBLISHED photo (never shown on the Page).
* Uses the client's own Page; Instagram-only clients use the agency's own Page (META_MEDIA_PAGE_ID in .env).
* Never another client's Page: their admins would see the upload in their media library.
*/
async function mediaPageFor(account) {
if (account.page_token) return account;
const id = process.env.META_MEDIA_PAGE_ID;
if (!id) throw new Error("Instagram-only clients need META_MEDIA_PAGE_ID in .env (the agency's own Facebook Page ID) to post images.");
const host = (await listAccounts()).find((a) => a.page_id === id && a.page_token);
if (!host) throw new Error("META_MEDIA_PAGE_ID doesn't match a Facebook Page the agency's system user can manage.");
return host;
}
export async function hostImage(account, filePath) { export async function hostImage(account, filePath) {
const up = await graphUpload(`${account.page_id}/photos`, { published: false }, filePath, { token: account.page_token }); const host = await mediaPageFor(account);
const info = await graph(up.id, { fields: "images" }, { token: account.page_token }); const up = await graphUpload(`${host.page_id}/photos`, { published: false }, filePath, { token: host.page_token });
const info = await graph(up.id, { fields: "images" }, { token: host.page_token });
const best = (info.images || []).sort((a, b) => b.width - a.width)[0]; const best = (info.images || []).sort((a, b) => b.width - a.width)[0];
if (!best?.source) throw new Error("Couldn't get a public URL for the uploaded image."); if (!best?.source) throw new Error("Couldn't get a public URL for the uploaded image.");
return best.source; return best.source;
@ -407,6 +451,7 @@ export async function igPublishAny(account, { type = "IMAGE", media = [], captio
* scheduledAt: Date — if given and 10+ minutes ahead, Facebook holds and publishes it (shows in Meta Planner). * scheduledAt: Date — if given and 10+ minutes ahead, Facebook holds and publishes it (shows in Meta Planner).
*/ */
export async function fbPublishAny(account, { message = "", media = [], link, scheduledAt } = {}) { export async function fbPublishAny(account, { message = "", media = [], link, scheduledAt } = {}) {
needPage(account);
const sched = scheduledAt && scheduledAt - Date.now() > 10 * 60000 const sched = scheduledAt && scheduledAt - Date.now() > 10 * 60000
? { published: false, scheduled_publish_time: Math.floor(scheduledAt / 1000) } : {}; ? { published: false, scheduled_publish_time: Math.floor(scheduledAt / 1000) } : {};
const tok = { token: account.page_token }; const tok = { token: account.page_token };
@ -439,6 +484,7 @@ export async function fbPublishAny(account, { message = "", media = [], link, sc
/** Facebook posts already scheduled natively (Meta Planner). */ /** Facebook posts already scheduled natively (Meta Planner). */
export async function fbScheduledPosts(account) { export async function fbScheduledPosts(account) {
needPage(account);
const r = await graph(`${account.page_id}/scheduled_posts`, { fields: "id,message,scheduled_publish_time,created_time" }, { token: account.page_token }); const r = await graph(`${account.page_id}/scheduled_posts`, { fields: "id,message,scheduled_publish_time,created_time" }, { token: account.page_token });
return r.data || []; return r.data || [];
} }

View File

@ -43,7 +43,7 @@ export function clientInfo(slug) {
if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || ""))) throw err("Unknown client.", 404); if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || ""))) throw err("Unknown client.", 404);
const file = path.join(CLIENTS, slug, "client.json"); const file = path.join(CLIENTS, slug, "client.json");
let link = {}; try { link = JSON.parse(fs.readFileSync(file, "utf8")); } catch { /* not linked */ } let link = {}; try { link = JSON.parse(fs.readFileSync(file, "utf8")); } catch { /* not linked */ }
return { slug, name: link.name || slug, pageId: link.pageId || null, timezone: link.timezone || "America/Toronto", market: link.market || null }; return { slug, name: link.name || slug, pageId: link.pageId || null, igId: link.igId || null, timezone: link.timezone || "America/Toronto", market: link.market || null };
} }
/** Good default posting slots by market, in the client's local time (adjust per client from insights). */ /** Good default posting slots by market, in the client's local time (adjust per client from insights). */
@ -55,9 +55,9 @@ export function suggestedTimes(tz) {
async function accountFor(slug) { async function accountFor(slug) {
const c = clientInfo(slug); const c = clientInfo(slug);
if (!c.pageId) throw err(`${c.name} isn't linked to a Facebook Page yet.`); if (!c.pageId && !c.igId) throw err(`${c.name} isn't linked to a Facebook Page or Instagram account yet.`);
const a = (await meta.listAccounts()).find((x) => x.page_id === c.pageId) || (await meta.listAccounts({ refresh: true })).find((x) => x.page_id === c.pageId); const a = await meta.accountForLink(c);
if (!a) throw err(`The agency can't see ${c.name}'s Page. Assign it to the social-agent system user.`); if (!a) throw err(`The agency can't see ${c.name}'s account. Assign it to the social-agent system user.`);
return a; return a;
} }
@ -82,6 +82,11 @@ export function createPost({ client, platforms = ["instagram"], type = "IMAGE",
if (!TYPES.includes(type)) throw err(`Type must be one of ${TYPES.join(", ")}.`); if (!TYPES.includes(type)) throw err(`Type must be one of ${TYPES.join(", ")}.`);
platforms = [...new Set(platforms)].filter((p) => p === "instagram" || p === "facebook"); platforms = [...new Set(platforms)].filter((p) => p === "instagram" || p === "facebook");
if (!platforms.length) throw err("Pick Instagram, Facebook or both."); if (!platforms.length) throw err("Pick Instagram, Facebook or both.");
// Instagram-only clients (no Facebook Page): drop Facebook when Instagram is also picked.
if (!c.pageId && c.igId) {
if (!platforms.includes("instagram")) throw err(`${c.name} is Instagram-only (no Facebook Page). Post to Instagram instead.`);
platforms = ["instagram"];
}
if (type === "TEXT" && platforms.includes("instagram")) throw err("Instagram needs an image or video. Use Facebook only for text posts."); if (type === "TEXT" && platforms.includes("instagram")) throw err("Instagram needs an image or video. Use Facebook only for text posts.");
if (type !== "TEXT" && !media.length) throw err("Add at least one image or video (file path or public URL)."); if (type !== "TEXT" && !media.length) throw err("Add at least one image or video (file path or public URL).");
for (const m of media) if (!/^https?:\/\//.test(m) && !fs.existsSync(m)) throw err(`File not found: ${m}`); for (const m of media) if (!/^https?:\/\//.test(m) && !fs.existsSync(m)) throw err(`File not found: ${m}`);

View File

@ -89,7 +89,8 @@ export function listClients() {
igUsername: link.igUsername || null, igUsername: link.igUsername || null,
category: link.category || null, category: link.category || null,
odooProject: link.odooProject || null, odooProject: link.odooProject || null,
linked: Boolean(link.pageId), linked: Boolean(link.pageId || link.igId),
igOnly: Boolean(!link.pageId && link.igId),
setup: setupStatus(dir), setup: setupStatus(dir),
lastAuditAt: lastAudit?.updatedAt || null, lastAuditAt: lastAudit?.updatedAt || null,
lastReportAt: lastReport?.updatedAt || null, lastReportAt: lastReport?.updatedAt || null,
@ -114,18 +115,19 @@ export function saveLink(slug, patch) {
/** Meta accounts the agency can manage that no client folder is linked to yet. */ /** Meta accounts the agency can manage that no client folder is linked to yet. */
export async function unlinkedAccounts({ refresh = false } = {}) { export async function unlinkedAccounts({ refresh = false } = {}) {
const accounts = await meta.listAccounts({ refresh }); const accounts = await meta.listAccounts({ refresh });
const linked = new Set(listClients().map((c) => c.pageId).filter(Boolean)); const linked = new Set(listClients().flatMap((c) => [c.pageId, c.igId && `ig:${c.igId}`]).filter(Boolean));
return accounts.filter((a) => !linked.has(a.page_id)).map(meta.publicAccount); 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). */ /** 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) { export async function linkToAccount(slug, accountId) {
const client = getClient(slug); const client = getClient(slug);
if (!client) throw Object.assign(new Error("Client not found."), { status: 404 }); 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)); const account = await findConnected(accountId);
if (!account) throw Object.assign(new Error("That Page isn't connected to the agency's Meta account."), { status: 400 }); const other = clientUsing(account, slug);
const other = listClients().find((c) => c.pageId === account.page_id && c.slug !== slug); if (other) throw Object.assign(new Error(`That account is already linked to ${other.name}.`), { status: 409 });
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 }); return saveLink(slug, { ...linkFields(account), name: client.name || account.page_name });
} }
@ -148,6 +150,18 @@ export async function autoLink() {
return results; return results;
} }
/** A connected account by its id: a Page id, or "ig:<instagram id>" 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) => ({ const linkFields = (a) => ({
name: a.page_name, name: a.page_name,
pageId: a.page_id, pageId: a.page_id,
@ -158,13 +172,13 @@ const linkFields = (a) => ({
}); });
/** "Add business": create clients/<slug>/ from the template for a connected Meta account. */ /** "Add business": create clients/<slug>/ from the template for a connected Meta account. */
export async function addClientFromAccount(pageId, { name } = {}) { export async function addClientFromAccount(accountId, { name } = {}) {
const account = (await meta.listAccounts({ refresh: true })).find((a) => a.page_id === String(pageId)); const account = await findConnected(accountId);
if (!account) throw Object.assign(new Error("That Page isn't connected to the agency's Meta account."), { status: 400 }); if (clientUsing(account)) throw Object.assign(new Error("This business already has a client folder."), { status: 409 });
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(); const displayName = (name || account.page_name).trim();
let slug = slugify(displayName) || `client-${account.page_id}`; const idTail = (account.page_id || account.instagram.id).slice(-4);
if (fs.existsSync(path.join(CLIENTS_DIR, slug))) slug = `${slug}-${account.page_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); const dir = path.join(CLIENTS_DIR, slug);
fs.cpSync(TEMPLATE_DIR, dir, { recursive: true }); fs.cpSync(TEMPLATE_DIR, dir, { recursive: true });
for (const sub of Object.values(DOC_DIRS)) fs.mkdirSync(path.join(dir, sub), { recursive: true }); for (const sub of Object.values(DOC_DIRS)) fs.mkdirSync(path.join(dir, sub), { recursive: true });
@ -175,7 +189,7 @@ export async function addClientFromAccount(pageId, { name } = {}) {
let text = fs.readFileSync(file, "utf8").replace(/^#\s*.*?(—|-)\s*/m, `# ${displayName} — `); let text = fs.readFileSync(file, "utf8").replace(/^#\s*.*?(—|-)\s*/m, `# ${displayName} — `);
if (f === "brand.md") { if (f === "brand.md") {
const ig = account.instagram?.username ? `Instagram @${account.instagram.username}` : "Instagram …"; 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 …`); 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}`); if (account.category) text = text.replace(/^\*\*Business type:\*\*.*$/m, `**Business type:** ${account.category}`);
} }
fs.writeFileSync(file, text); fs.writeFileSync(file, text);

View File

@ -128,7 +128,7 @@ app.get("/api/clients/:slug", h(async (req) => {
app.post("/api/clients/:slug/refresh", h(async (req) => { app.post("/api/clients/:slug/refresh", h(async (req) => {
clients.safeSlug(req.params.slug); clients.safeSlug(req.params.slug);
if (!clients.getClient(req.params.slug)?.linked) throw Object.assign(new Error("This client isn't linked to a Facebook Page yet, so there are no numbers to fetch. Link a Page first."), { status: 400 }); if (!clients.getClient(req.params.slug)?.linked) throw Object.assign(new Error("This client isn't linked to a Facebook Page or Instagram account yet, so there are no numbers to fetch. Link an account first."), { status: 400 });
const wait = req.query.wait === "1"; const wait = req.query.wait === "1";
const p = metrics.refreshSnapshot(req.params.slug); const p = metrics.refreshSnapshot(req.params.slug);
if (wait) return { snapshot: await p }; if (wait) return { snapshot: await p };
@ -143,14 +143,14 @@ app.patch("/api/clients/:slug/link", requireAdmin, h(async (req) => {
})); }));
app.post("/api/clients/:slug/link-page", requireAdmin, h(async (req) => { app.post("/api/clients/:slug/link-page", requireAdmin, h(async (req) => {
const link = await clients.linkToAccount(req.params.slug, req.body?.pageId); const link = await clients.linkToAccount(req.params.slug, req.body?.accountId || req.body?.pageId);
metrics.refreshSnapshot(req.params.slug).catch((e) => console.error("first refresh", req.params.slug, e.message)); metrics.refreshSnapshot(req.params.slug).catch((e) => console.error("first refresh", req.params.slug, e.message));
return { link }; return { link };
})); }));
app.get("/api/accounts/unlinked", h(async (req) => ({ accounts: await clients.unlinkedAccounts({ refresh: req.query.refresh === "1" }) }))); app.get("/api/accounts/unlinked", h(async (req) => ({ accounts: await clients.unlinkedAccounts({ refresh: req.query.refresh === "1" }) })));
app.post("/api/clients", requireAdmin, h(async (req) => { app.post("/api/clients", requireAdmin, h(async (req) => {
const client = await clients.addClientFromAccount(req.body?.pageId, { name: req.body?.name }); const client = await clients.addClientFromAccount(req.body?.accountId || req.body?.pageId, { name: req.body?.name });
metrics.refreshSnapshot(client.slug).catch((e) => console.error("first refresh", client.slug, e.message)); metrics.refreshSnapshot(client.slug).catch((e) => console.error("first refresh", client.slug, e.message));
if (req.body?.runAudit) jobs.createJob({ type: "account-audit", slug: client.slug }, req.user); if (req.body?.runAudit) jobs.createJob({ type: "account-audit", slug: client.slug }, req.user);
return { client }; return { client };
@ -196,9 +196,9 @@ const MEDIA_DIR = path.join(path.dirname(_DD), "schedule", "media");
fs.mkdirSync(MEDIA_DIR, { recursive: true }); fs.mkdirSync(MEDIA_DIR, { recursive: true });
const clientAccount = async (slug) => { const clientAccount = async (slug) => {
const c = clients.getClient(slug); const c = clients.getClient(slug);
if (!c?.pageId) throw Object.assign(new Error("This client isn't linked to a Facebook Page yet."), { status: 400 }); if (!c?.linked) throw Object.assign(new Error("This client isn't linked to a Facebook Page or Instagram account yet."), { status: 400 });
const a = (await meta.listAccounts()).find((x) => x.page_id === c.pageId); const a = await meta.accountForLink(c);
if (!a) throw Object.assign(new Error("The agency can't see this client's Page."), { status: 400 }); if (!a) throw Object.assign(new Error("The agency can't see this client's account."), { status: 400 });
return a; return a;
}; };
const needPublishing = () => { if (process.env.ALLOW_PUBLISH !== "true") throw Object.assign(new Error("Publishing is switched off. Set ALLOW_PUBLISH=true in .env and restart."), { status: 403 }); }; const needPublishing = () => { if (process.env.ALLOW_PUBLISH !== "true") throw Object.assign(new Error("Publishing is switched off. Set ALLOW_PUBLISH=true in .env and restart."), { status: 403 }); };

View File

@ -57,8 +57,8 @@ function derive(posts) {
} }
async function fetchSnapshot(client) { async function fetchSnapshot(client) {
const account = await meta.findAccount(client.pageId); const account = await meta.accountForLink(client);
if (!account) throw new Error("The linked Facebook Page is no longer connected to the agency's Meta account."); if (!account) throw new Error("The linked account is no longer connected to the agency's Meta account.");
const snap = { slug: client.slug, fetchedAt: new Date().toISOString(), ig: null, fb: null, errors: [] }; const snap = { slug: client.slug, fetchedAt: new Date().toISOString(), ig: null, fb: null, errors: [] };
if (account.instagram?.id) { if (account.instagram?.id) {
@ -79,7 +79,7 @@ async function fetchSnapshot(client) {
snap.errors.push(`Instagram: ${e.message}`); snap.errors.push(`Instagram: ${e.message}`);
} }
} }
try { if (account.page_id) try {
const page = await meta.pageInsights(account, { sinceDays: 28 }); const page = await meta.pageInsights(account, { sinceDays: 28 });
const posts = await meta.pageRecentPosts(account, { limit: 10 }); const posts = await meta.pageRecentPosts(account, { limit: 10 });
snap.fb = { page: page.page, totals: page.totals, metricErrors: page.errors, posts: posts.map((p) => ({ ...p, message: (p.message || "").slice(0, 600) })) }; snap.fb = { page: page.page, totals: page.totals, metricErrors: page.errors, posts: posts.map((p) => ({ ...p, message: (p.message || "").slice(0, 600) })) };

View File

@ -25,7 +25,7 @@ export default function AddClientDialog({ open, onClose, onAdded }) {
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
const r = await api("/api/clients", { method: "POST", body: { pageId: picked.page_id, name, runAudit } }); const r = await api("/api/clients", { method: "POST", body: { accountId: picked.account_id, name, runAudit } });
onClose(); onClose();
onAdded?.(r.client); onAdded?.(r.client);
} catch (e) { } catch (e) {
@ -63,16 +63,16 @@ export default function AddClientDialog({ open, onClose, onAdded }) {
) : ( ) : (
<ul className="border border-line rounded-lg divide-y divide-line max-h-[260px] overflow-y-auto"> <ul className="border border-line rounded-lg divide-y divide-line max-h-[260px] overflow-y-auto">
{accounts?.map((a) => ( {accounts?.map((a) => (
<li key={a.page_id}> <li key={a.account_id}>
<button <button
onClick={() => { setPicked(a); setName(a.page_name); }} onClick={() => { setPicked(a); setName(a.page_name); }}
className={cx("w-full text-left px-3.5 py-2.5 flex items-center gap-3", picked?.page_id === a.page_id ? "bg-brand-soft" : "hover:bg-[#f8fafc]")} className={cx("w-full text-left px-3.5 py-2.5 flex items-center gap-3", picked?.account_id === a.account_id ? "bg-brand-soft" : "hover:bg-[#f8fafc]")}
> >
<span className={cx("size-4 rounded-full border-2 shrink-0", picked?.page_id === a.page_id ? "border-brand bg-brand shadow-[inset_0_0_0_2px_white]" : "border-line-strong")} /> <span className={cx("size-4 rounded-full border-2 shrink-0", picked?.account_id === a.account_id ? "border-brand bg-brand shadow-[inset_0_0_0_2px_white]" : "border-line-strong")} />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block font-medium truncate">{a.page_name}</span> <span className="block font-medium truncate">{a.page_name}</span>
<span className="flex gap-3 text-[12px] text-muted"> <span className="flex gap-3 text-[12px] text-muted">
<span className="inline-flex items-center gap-1"><Facebook className="size-3" />{a.category || "Page"}</span> {a.page_id ? <span className="inline-flex items-center gap-1"><Facebook className="size-3" />{a.category || "Page"}</span> : <span>Instagram only · no Facebook Page</span>}
{a.instagram ? ( {a.instagram ? (
<span className="inline-flex items-center gap-1"><Instagram className="size-3" />@{a.instagram.username} · {fmt(a.instagram.followers_count)} followers</span> <span className="inline-flex items-center gap-1"><Instagram className="size-3" />@{a.instagram.username} · {fmt(a.instagram.followers_count)} followers</span>
) : ( ) : (

View File

@ -81,7 +81,7 @@ export default function Client() {
{data.refreshing ? "Fetching from Meta" : "Refresh numbers"} {data.refreshing ? "Fetching from Meta" : "Refresh numbers"}
</Button> </Button>
) : isAdmin && ( ) : isAdmin && (
<Button icon={Link2} onClick={() => setLinkOpen(true)}>Link a Page</Button> <Button icon={Link2} onClick={() => setLinkOpen(true)}>Link an account</Button>
)} )}
<Button variant="primary" icon={Sparkles} onClick={() => setRunOpen(true)}>Run with Claude</Button> <Button variant="primary" icon={Sparkles} onClick={() => setRunOpen(true)}>Run with Claude</Button>
</div> </div>
@ -104,8 +104,8 @@ export default function Client() {
? <Performance snapshot={snapshot} history={data.history} refreshing={data.refreshing} onRefresh={refresh} /> ? <Performance snapshot={snapshot} history={data.history} refreshing={data.refreshing} onRefresh={refresh} />
: ( : (
<Panel> <Panel>
<Empty icon={Link2} title="Not linked to a Facebook Page" action={isAdmin && <Button variant="primary" icon={Link2} onClick={() => setLinkOpen(true)}>Link a Page</Button>}> <Empty icon={Link2} title="Not linked to a Facebook Page or Instagram account" action={isAdmin && <Button variant="primary" icon={Link2} onClick={() => setLinkOpen(true)}>Link an account</Button>}>
Numbers, publishing and the inbox need this client's Facebook Page (and its Instagram). If the Page isn't in the list, Numbers, publishing and the inbox need this client's Facebook Page or Instagram account. If it isn't in the list,
assign it to the agency's system user in Meta Business Settings → System users → Assign assets, then check again. assign it to the agency's system user in Meta Business Settings → System users → Assign assets, then check again.
</Empty> </Empty>
</Panel> </Panel>
@ -134,28 +134,28 @@ function LinkPageDialog({ client, onClose, onLinked }) {
const link = async () => { const link = async () => {
setBusy(true); setError(null); setBusy(true); setError(null);
try { await api(`/api/clients/${client.slug}/link-page`, { method: "POST", body: { pageId: picked.page_id } }); onLinked(); } try { await api(`/api/clients/${client.slug}/link-page`, { method: "POST", body: { accountId: picked.account_id } }); onLinked(); }
catch (e) { setError(e); setBusy(false); } catch (e) { setError(e); setBusy(false); }
}; };
return ( return (
<Dialog open onClose={onClose} title={`Link ${client.name} to a Page`} <Dialog open onClose={onClose} title={`Link ${client.name} to an account`}
footer={<><Button variant="ghost" onClick={onClose}>Cancel</Button><Button variant="primary" disabled={!picked} loading={busy} onClick={link}>Link and fetch numbers</Button></>}> footer={<><Button variant="ghost" onClick={onClose}>Cancel</Button><Button variant="primary" disabled={!picked} loading={busy} onClick={link}>Link and fetch numbers</Button></>}>
<p className="text-muted text-[13px] mb-3">Pages the agency's Meta account manages that no client is linked to yet. The client keeps its name and files.</p> <p className="text-muted text-[13px] mb-3">Facebook Pages and Instagram accounts the agency's Meta account manages that no client is linked to yet. The client keeps its name and files.</p>
<ErrorNote error={error} className="mb-3" /> <ErrorNote error={error} className="mb-3" />
<div className="flex justify-end mb-2"><Button size="sm" variant="ghost" icon={RefreshCw} onClick={check}>Check Meta again</Button></div> <div className="flex justify-end mb-2"><Button size="sm" variant="ghost" icon={RefreshCw} onClick={check}>Check Meta again</Button></div>
{!accounts && !error ? <Spinner label="Asking Meta…" /> : accounts?.length === 0 ? ( {!accounts && !error ? <Spinner label="Asking Meta…" /> : accounts?.length === 0 ? (
<Empty title="No unlinked Pages">Assign {client.name}'s Page and Instagram to the agency's system user in Meta Business Settings → System users → Assign assets, then press Check Meta again.</Empty> <Empty title="No unlinked accounts">Assign {client.name}'s Page or Instagram account to the agency's system user in Meta Business Settings → System users → Assign assets, then press Check Meta again.</Empty>
) : ( ) : (
<ul className="border border-line rounded-lg divide-y divide-line max-h-[260px] overflow-y-auto"> <ul className="border border-line rounded-lg divide-y divide-line max-h-[260px] overflow-y-auto">
{accounts?.map((a) => ( {accounts?.map((a) => (
<li key={a.page_id}> <li key={a.account_id}>
<button type="button" onClick={() => setPicked(a)} className={cx("w-full text-left px-3.5 py-2.5 flex items-center gap-3", picked?.page_id === a.page_id ? "bg-brand-soft" : "hover:bg-canvas")}> <button type="button" onClick={() => setPicked(a)} className={cx("w-full text-left px-3.5 py-2.5 flex items-center gap-3", picked?.account_id === a.account_id ? "bg-brand-soft" : "hover:bg-canvas")}>
<span className={cx("size-4 rounded-full border-2 shrink-0", picked?.page_id === a.page_id ? "border-brand bg-brand shadow-[inset_0_0_0_2px_white]" : "border-line-strong")} /> <span className={cx("size-4 rounded-full border-2 shrink-0", picked?.account_id === a.account_id ? "border-brand bg-brand shadow-[inset_0_0_0_2px_white]" : "border-line-strong")} />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block font-medium truncate">{a.page_name}</span> <span className="block font-medium truncate">{a.page_name}</span>
<span className="flex gap-3 text-[12px] text-muted"> <span className="flex gap-3 text-[12px] text-muted">
<span className="inline-flex items-center gap-1"><Facebook className="size-3" />{a.category || "Page"}</span> {a.page_id ? <span className="inline-flex items-center gap-1"><Facebook className="size-3" />{a.category || "Page"}</span> : <span>Instagram only · no Facebook Page</span>}
{a.instagram ? <span className="inline-flex items-center gap-1"><Instagram className="size-3" />@{a.instagram.username}</span> : <span>no Instagram linked</span>} {a.instagram ? <span className="inline-flex items-center gap-1"><Instagram className="size-3" />@{a.instagram.username}</span> : <span>no Instagram linked</span>}
</span> </span>
</span> </span>