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:
parent
7f7e41b617
commit
3cbc76e7f1
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 || [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 }); };
|
||||||
|
|||||||
@ -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) })) };
|
||||||
|
|||||||
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user