metatroncubeswdev c309581df0 Initial commit: Quorla social media manager
Dashboard (content library, publishing queue with approvals, inbox, team
management, AI runs), Meta connector, branded PDF reports, agent skills,
and Docker packaging (Dockerfile, docker-compose with Caddy HTTPS,
backup and path-relocation scripts).

Client data, live data and secrets are excluded via .gitignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:02:29 -04:00

177 lines
7.4 KiB
JavaScript

// Live Instagram / Facebook numbers per client, cached on disk.
// Per-post insights take ~5 Graph calls per post, so a full refresh is slow; the UI shows the
// last snapshot instantly and refreshes on demand (or on the nightly schedule).
import fs from "node:fs";
import path from "node:path";
import { DATA_DIR } from "./config.js";
import { getClient, listClients } from "./clients.js";
import * as meta from "../../connector/src/meta.js";
const SNAP_DIR = path.join(DATA_DIR, "snapshots");
const HIST_DIR = path.join(DATA_DIR, "history");
fs.mkdirSync(SNAP_DIR, { recursive: true });
fs.mkdirSync(HIST_DIR, { recursive: true });
const DAY = 86400 * 1000;
const inFlight = new Map();
const avg = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : null);
async function dailySeries(igId, metric, days = 30) {
try {
const since = Math.floor((Date.now() - days * DAY) / 1000);
const r = await meta.graph(`${igId}/insights`, { metric, period: "day", since, until: Math.floor(Date.now() / 1000) });
return (r.data?.[0]?.values || []).map((v) => ({ date: v.end_time.slice(0, 10), value: v.value ?? 0 }));
} catch {
return []; // follower_count needs 100+ followers; some metrics aren't available on every account
}
}
function derive(posts) {
const now = Date.now();
const recent = posts.filter((p) => now - new Date(p.timestamp) < 28 * DAY);
const isReel = (p) => p.media_product_type === "REELS" || p.media_type === "VIDEO";
const isCarousel = (p) => p.media_type === "CAROUSEL_ALBUM";
// Posts published in the last 48h haven't finished collecting reach; leave them out of averages.
const settled = posts.filter((p) => now - new Date(p.timestamp) > 2 * DAY && p.insights?.reach != null);
const sorted = [...posts].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
const gaps = sorted.slice(0, -1).map((p, i) => (new Date(p.timestamp) - new Date(sorted[i + 1].timestamp)) / DAY);
return {
postsLast28: recent.length,
daysSinceLastPost: sorted[0] ? Math.floor((now - new Date(sorted[0].timestamp)) / DAY) : null,
longestGapDays: gaps.length ? Math.round(Math.max(...gaps)) : null,
formatMix: {
reels: posts.filter(isReel).length,
carousels: posts.filter(isCarousel).length,
images: posts.filter((p) => !isReel(p) && !isCarousel(p)).length,
},
avgReach: {
reels: avg(settled.filter(isReel).map((p) => p.insights.reach)),
carousels: avg(settled.filter(isCarousel).map((p) => p.insights.reach)),
images: avg(settled.filter((p) => !isReel(p) && !isCarousel(p)).map((p) => p.insights.reach)),
},
savesTotal: posts.reduce((s, p) => s + (p.insights?.saved || 0), 0),
sharesTotal: posts.reduce((s, p) => s + (p.insights?.shares || 0), 0),
commentsTotal: posts.reduce((s, p) => s + (p.comments_count || 0), 0),
};
}
async function fetchSnapshot(client) {
const account = await meta.findAccount(client.pageId);
if (!account) throw new Error("The linked Facebook Page is no longer connected to the agency's Meta account.");
const snap = { slug: client.slug, fetchedAt: new Date().toISOString(), ig: null, fb: null, errors: [] };
if (account.instagram?.id) {
try {
const igId = account.instagram.id;
const insights = await meta.igAccountInsights(igId, { sinceDays: 28 });
const posts = await meta.igRecentMedia(igId, { limit: 20 });
snap.ig = {
profile: insights.profile,
totals: insights.totals,
metricErrors: insights.errors,
dailyReach: await dailySeries(igId, "reach"),
dailyFollowers: await dailySeries(igId, "follower_count"),
posts: posts.map((p) => ({ ...p, caption: (p.caption || "").slice(0, 600) })),
derived: derive(posts),
};
} catch (e) {
snap.errors.push(`Instagram: ${e.message}`);
}
}
try {
const page = await meta.pageInsights(account, { sinceDays: 28 });
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) })) };
} catch (e) {
snap.errors.push(`Facebook: ${e.message}`);
}
return snap;
}
function appendHistory(snap) {
const file = path.join(HIST_DIR, `${snap.slug}.jsonl`);
const today = snap.fetchedAt.slice(0, 10);
const lines = fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean) : [];
const row = {
date: today,
igFollowers: snap.ig?.profile?.followers_count ?? null,
igReach28: snap.ig?.totals?.reach ?? null,
igInteractions28: snap.ig?.totals?.total_interactions ?? null,
igLinkTaps28: snap.ig?.totals?.profile_links_taps ?? null,
fbFollowers: snap.fb?.page?.followers_count ?? null,
fbViewers28: snap.fb?.totals?.page_total_media_view_unique ?? null,
};
const kept = lines.filter((l) => !l.startsWith(`{"date":"${today}"`));
kept.push(JSON.stringify(row));
fs.writeFileSync(file, kept.slice(-400).join("\n") + "\n");
}
export function readHistory(slug) {
const file = path.join(HIST_DIR, `${slug}.jsonl`);
if (!fs.existsSync(file)) return [];
return fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
}
export function readSnapshot(slug) {
try { return JSON.parse(fs.readFileSync(path.join(SNAP_DIR, `${slug}.json`), "utf8")); } catch { return null; }
}
export function isRefreshing(slug) {
return inFlight.has(slug);
}
/** Fetch fresh numbers (deduplicated: concurrent callers share one fetch). */
export function refreshSnapshot(slug) {
if (inFlight.has(slug)) return inFlight.get(slug);
const client = getClient(slug);
if (!client?.linked) return Promise.reject(Object.assign(new Error("Link this client to a Facebook Page first."), { status: 400 }));
const job = fetchSnapshot(client)
.then((snap) => {
fs.writeFileSync(path.join(SNAP_DIR, `${slug}.json`), JSON.stringify(snap));
appendHistory(snap);
return snap;
})
.finally(() => inFlight.delete(slug));
inFlight.set(slug, job);
return job;
}
/** Refresh every linked client one at a time (keeps Graph API rate limits happy). */
export async function refreshAll({ olderThanHours = 0 } = {}) {
const done = [];
for (const c of listClients().filter((x) => x.linked)) {
const snap = readSnapshot(c.slug);
if (snap && Date.now() - new Date(snap.fetchedAt) < olderThanHours * 3600 * 1000) continue;
try {
await refreshSnapshot(c.slug);
done.push({ slug: c.slug, ok: true });
} catch (e) {
done.push({ slug: c.slug, ok: false, error: e.message });
}
}
return done;
}
/** Compact per-client summary for the overview grid. */
export function summary(client) {
const snap = readSnapshot(client.slug);
const ig = snap?.ig;
const posts = ig?.posts || [];
return {
...client,
fetchedAt: snap?.fetchedAt || null,
refreshing: isRefreshing(client.slug),
errors: snap?.errors || [],
igFollowers: ig?.profile?.followers_count ?? null,
igReach28: ig?.totals?.reach ?? null,
igInteractions28: ig?.totals?.total_interactions ?? null,
igLinkTaps28: ig?.totals?.profile_links_taps ?? null,
fbFollowers: snap?.fb?.page?.followers_count ?? null,
fbViewers28: snap?.fb?.totals?.page_total_media_view_unique ?? null,
postsLast28: ig?.derived?.postsLast28 ?? null,
daysSinceLastPost: ig?.derived?.daysSinceLastPost ?? null,
reachSpark: posts.slice(0, 12).reverse().map((p) => p.insights?.reach ?? 0),
};
}