Explain why Fetch now fails for unlinked clients; let admins link a Page

- 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>
This commit is contained in:
metatroncubeswdev 2026-10-19 23:35:46 -04:00
parent e107c046a2
commit 7f7e41b617
3 changed files with 90 additions and 5 deletions

View File

@ -118,6 +118,17 @@ export async function unlinkedAccounts({ refresh = false } = {}) {
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();

View File

@ -128,6 +128,7 @@ app.get("/api/clients/:slug", h(async (req) => {
app.post("/api/clients/:slug/refresh", h(async (req) => {
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 });
const wait = req.query.wait === "1";
const p = metrics.refreshSnapshot(req.params.slug);
if (wait) return { snapshot: await p };
@ -141,6 +142,11 @@ app.patch("/api/clients/:slug/link", requireAdmin, h(async (req) => {
return { link: clients.saveLink(req.params.slug, patch) };
}));
app.post("/api/clients/:slug/link-page", requireAdmin, h(async (req) => {
const link = await clients.linkToAccount(req.params.slug, req.body?.pageId);
metrics.refreshSnapshot(req.params.slug).catch((e) => console.error("first refresh", req.params.slug, e.message));
return { link };
}));
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) => {

View File

@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Bar, BarChart, CartesianGrid, Cell, Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, FileDown, FileText, Eye, RefreshCw, Sparkles, Briefcase } from "lucide-react";
import { ArrowLeft, ArrowUpRight, ClipboardCheck, CalendarRange, FileBarChart, FileDown, FileText, Eye, RefreshCw, Sparkles, Briefcase, Link2, Facebook, Instagram } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import {
Badge, Button, cx, Dialog, Empty, ErrorNote, fmt, fmtDate, fmtDateTime, Panel, Spinner, Stat, StatCell, StatRow, STATUS_TONE, Tabs, timeAgo,
} from "../ui.jsx";
@ -27,6 +28,8 @@ export default function Client() {
const [error, setError] = useState(null);
const [tab, setTab] = useState("performance");
const [runOpen, setRunOpen] = useState(false);
const [linkOpen, setLinkOpen] = useState(false);
const { isAdmin } = useAuth();
const load = useCallback(() => api(`/api/clients/${slug}`).then(setData).catch(setError), [slug]);
useEffect(() => { setData(null); load(); }, [load]);
@ -37,6 +40,7 @@ export default function Client() {
}, [data?.refreshing, load]);
const refresh = async () => {
setError(null);
await api(`/api/clients/${slug}/refresh`, { method: "POST" }).catch(setError);
load();
};
@ -72,13 +76,18 @@ export default function Client() {
</div>
</div>
<div className="flex gap-2 shrink-0">
<Button icon={RefreshCw} onClick={refresh} loading={data.refreshing}>
{data.refreshing ? "Fetching from Meta" : "Refresh numbers"}
</Button>
{client.linked ? (
<Button icon={RefreshCw} onClick={refresh} loading={data.refreshing}>
{data.refreshing ? "Fetching from Meta" : "Refresh numbers"}
</Button>
) : isAdmin && (
<Button icon={Link2} onClick={() => setLinkOpen(true)}>Link a Page</Button>
)}
<Button variant="primary" icon={Sparkles} onClick={() => setRunOpen(true)}>Run with Claude</Button>
</div>
</header>
<ErrorNote error={error} />
<SetupHints client={client} />
<Tabs
@ -91,15 +100,74 @@ export default function Client() {
]}
/>
{tab === "performance" && <Performance snapshot={snapshot} history={data.history} refreshing={data.refreshing} onRefresh={refresh} />}
{tab === "performance" && (client.linked
? <Performance snapshot={snapshot} history={data.history} refreshing={data.refreshing} onRefresh={refresh} />
: (
<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>}>
Numbers, publishing and the inbox need this client's Facebook Page (and its Instagram). If the Page isn't in the list,
assign it to the agency's system user in Meta Business Settings → System users → Assign assets, then check again.
</Empty>
</Panel>
))}
{tab === "docs" && <Documents slug={slug} docs={data.docs} reload={load} />}
{tab === "runs" && <ClientRuns jobs={data.jobs} />}
<RunDialog open={runOpen} onClose={() => setRunOpen(false)} client={client} onStarted={(job) => nav(`/runs/${job.id}`)} />
{linkOpen && <LinkPageDialog client={client} onClose={() => setLinkOpen(false)} onLinked={() => { setLinkOpen(false); load(); }} />}
</div>
);
}
/** Link an existing client folder to a Page the agency's Meta account manages. */
function LinkPageDialog({ client, onClose, onLinked }) {
const [accounts, setAccounts] = useState(null);
const [picked, setPicked] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const check = useCallback(() => {
setAccounts(null); setError(null);
api("/api/accounts/unlinked?refresh=1").then((r) => setAccounts(r.accounts)).catch(setError);
}, []);
useEffect(() => { check(); }, [check]);
const link = async () => {
setBusy(true); setError(null);
try { await api(`/api/clients/${client.slug}/link-page`, { method: "POST", body: { pageId: picked.page_id } }); onLinked(); }
catch (e) { setError(e); setBusy(false); }
};
return (
<Dialog open onClose={onClose} title={`Link ${client.name} to a Page`}
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>
<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>
{!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>
) : (
<ul className="border border-line rounded-lg divide-y divide-line max-h-[260px] overflow-y-auto">
{accounts?.map((a) => (
<li key={a.page_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")}>
<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="min-w-0 flex-1">
<span className="block font-medium truncate">{a.page_name}</span>
<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.instagram ? <span className="inline-flex items-center gap-1"><Instagram className="size-3" />@{a.instagram.username}</span> : <span>no Instagram linked</span>}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</Dialog>
);
}
function SetupHints({ client }) {
const notes = [];
if (client.setup.brand.draft) notes.push("brand.md is a DRAFT. Confirm it with the client.");