# Mini-package: Facebook posts in the Posts section

> Authored by the orchestrator (Fable), 2026-07-20. **Authority:** operator
> directive 2026-07-20 ("also add facebook posts to posts section",
> DECISIONS same date) + the announced default the operator did not object
> to: **mirror the IG model** — sync the FB page feed on the Posts sync
> action + keep comment-time discovery; shown in the existing Posts grid
> with a channel dimension. Channels scope mini-package; the gate judges
> against this document.

## Scope

1. **Ingest:** FB page posts enter `social_posts` (or its FB-shaped
   equivalent — additive migration if a channel/source discriminator is
   missing). Two paths, mirroring IG: (a) the existing "Sync posts" action
   also pulls the page feed via Graph with the stored Page token;
   (b) comment-time discovery — an FB comment on an unknown post ingests
   that post (the IG ad-post pattern).
2. **Interpretation:** FB captions flow through the SAME auto-interpret +
   caption-cache pipeline (interpret-2 standalone prompt; no backfill —
   new arrivals only, per the standing operator correction).
3. **Web:** the Posts screen gains an honest channel dimension (IG /
   Facebook — tab or filter, executor's judgment, house style: no pills,
   icons not emojis). FB rows show what Graph actually returns; no faked
   parity with IG fields.
4. **Token honesty:** reading the page feed may require
   `pages_read_user_content`, which is on the operator-blocked 4-scope
   token list. Build + test against recorded/mocked Graph shapes; attempt
   one live read; if it 403s on scope, record it as blocked-on-token in the
   stage log (that is a PASSING outcome for the gate — the operator's token
   regen unblocks it later). Do NOT invent workarounds.

## Standing rules

TDD red-first; capped-fork per-package suites; `corepack pnpm`; additive
migrations only; Zod at boundaries; no gate flips; no LLM spend (the
interpret path runs only on genuinely new posts in prod — do not trigger it
in tests; mock the LLM client); pathspec commits, `git pull --rebase`
before push, push per verified stage; deploy/publish is the orchestrator's
lane. Screenshots before publish.

## Gate bar

1. Suites green (shared/api/web as touched), typecheck/lint/build exit 0;
   migration (if any) additive and applies cleanly.
2. IG behaviour unchanged — existing posts tests pass unmodified where the
   contract is untouched; deliberate flips named per-commit.
3. FB sync path test-pinned against recorded Graph shapes; comment-time
   discovery pinned; dedupe holds (no duplicate rows on re-sync).
4. One live check at deploy (orchestrator): sync runs; FB rows appear OR
   the 403-on-scope is recorded verbatim as blocked-on-token.
5. Posts screen channel dimension screenshot (1440 + 375) delivered before
   publish; style law holds.
6. Deferrals to the board; stage log current.

## Out of scope

- FB DMs/messenger; FB posting/publishing; Marketing-API ad sync (ruled
  unnecessary 2026-07-17); backfill interpretation of existing rows.

## Stage log

*(executor appends; gate verdict recorded here)*

### Executor (Opus) — fb-posts, 2026-07-20

**No migration.** `social_posts` already carries `channel` (default "instagram")
+ `@@unique([channel, externalId])`, and `PostsService.list()` already spans
channels. FB is `channel:"facebook"` rows — additive by data, zero schema change.

**Stage 1 — shared FacebookClient reads · sha `11ec5ad`**
`FacebookClient.listPagePosts()` (`GET /me/posts`) + `getPost()` (`GET /{id}`),
FB counterparts to `InstagramClient.listMedia/getMedia`; new `FbPost` shape
(message→caption, permalink_url→permalink, created_time→timestamp,
full_picture→thumbnailUrl); error-as-data ([] / null; a 403 is the honest empty).
Oracle `npx vitest run src/facebook/facebook-client.test.ts` (red first: 6 failed
"getPost is not a function"; green):
```
Test Files  1 passed (1)
     Tests  20 passed (20)
```
`corepack pnpm --filter @channels/shared build` → tsc exit 0.

**Stage 2+3 — FbPostsService + wiring · sha `f17aa6b`**
`FbPostsService` (facebook module) mirrors the IG paths writing `channel:facebook`
rows: `sync()` (`/me/posts`; missing connection or 403 ⇒ `synced:0`, never throws
— so the one Sync action never fails because FB is blocked), `contextForMedia()`
(comment-time discovery: unknown post → on-the-fly `ads` row, the IG ad-post
pattern), orphan heal + auto-interpret via the ONE shared `POST_INTERPRET_QUEUE`.
Wiring: FacebookModule provides+exports FbPostsService (+ own interpret-queue
provider); FbCommentService injects it (adapter `contextForMedia` delegates —
FB comments no longer caption-blind); PostsController folds `fbSvc.sync()` into
`POST /agent/posts/sync` (combined + per-channel counts); InstagramModule imports
FacebookModule (acyclic — FB never imports IG); `PostsService.detail` scopes
triggers to the post's own channel. Oracle
`npx vitest run src/facebook/fb-posts.service.test.ts src/facebook/fb-comment.service.test.ts src/instagram/posts.service.test.ts`:
```
Test Files  3 passed (3)
     Tests  59 passed (59)
```
(fb-posts red-first: methods undefined → 11 green; fb-comment +1 discovery test →
15; IG posts.service.test UNCHANGED and green — IG contract untouched.)
API typecheck: `tsc -p` over the whole api tree, excluding the three concurrent
seats' in-flight dirs (alerts/analytics/contacts, which do not compile in the
shared worktree yet) → **exit 0**. Full `pnpm --filter @channels/api build` is
currently RED, but ONLY on `src/alerts/notifications.controller.test.ts`
(markAllRead) — another seat's uncommitted WIP, not this package.

**Stage 4 — web channel dimension · sha `3f0e533`** (posts.css landed via a
concurrent web sweep, `d6ad0c3`; content matches this seat's final exactly)
Segmented channel switch (All / Instagram / Facebook + counts, shown only when
posts span >1 channel); per-tile brand-glyph badge (icons, not emojis); per-post
view shows the channel mark + channel-correct account name + "open on
Facebook/Instagram". Copy de-Instagram-ified; `syncPosts` returns combined +
per-channel counts. Oracle `npx vitest run src/routes/agent/Posts.test.tsx`:
```
Test Files  1 passed (1)
     Tests  26 passed (26)
```
(23 original + 3 new channel-dimension tests.) `pnpm --filter @channels/web build`
→ vite built in 9.22s (exit 0); `--filter @channels/web typecheck` → exit 0.
Screenshots (real component, React-Query cache seeded — the FB token’s live rows
are not synced into the dev DB): `plans/rethink/evidence/fb-posts-grid-1440.png`,
`fb-posts-grid-375.png` (regenerated below with the separated tabs).

**Live read (token honesty, §4).** READ-ONLY `GET /me/posts` on the VPS via the
stored `instagram` Page token (decrypted in-process, token NEVER printed),
apiVersion **v25.0**:
```
HTTP 200
BODY {"data":[{"id":"109939868479359_1027811480036581","message":"🇦🇪 UAE Introduces 5-Year Multiple-Entry Tourist Visa …
```
**Outcome: 200, real Page posts — NOT the expected 403.** `pages_read_user_content`
is effectively readable on the current test token, so FB rows WILL appear on the
first real sync (no token-regen needed to unblock). The returned id prefix
`109939868479359_…` matches the FB comment `post_id` prefix, so feed-sync ↔
comment-discovery dedup on `(channel,externalId)` aligns.

**NOT verified by this seat:** NestJS DI boot / `pm2 restart` + `/health` (deploy
lane — this seat does not deploy; the InstagramModule→FacebookModule edge and the
`POST_INTERPRET_QUEUE` cross-module provide are typed + acyclic but only proven at
boot); the deployed `POST /agent/posts/sync` end-to-end against live FB (the gate's
live check); `getPost` against a live single-post id (only `listPagePosts` was
exercised live). Full `pnpm build`/`pnpm test` fan-out was NOT run (PID cap +
other seats' uncommitted red state) — verification was per-package + scoped tsc.

### Executor (Opus) — operator directives follow-up, 2026-07-20

Two operator changes on top of the above.

**(1) SEPARATION — channels are tabs, never a mixed grid · sha `ec442b3`**
Replaced the All/Instagram/Facebook segmented switch with a PRIMARY channel tab
row (house tab pattern, identical to the organic/ads row): `Instagram | Facebook`,
each with an honest count, both always shown, **default Instagram** (the richer
set today). No "All" — the grid renders exactly one channel at a time. Secondary
organic/ads tabs stay, scoped to the selected channel; per-channel empty note
("No Facebook posts yet"). Oracle `npx vitest run src/routes/agent/Posts.test.tsx`
→ `27 passed` (channel-dimension tests rewritten deliberately: no-All, default-IG,
tab-swaps-not-mixes, FB-empty-note). `--filter @channels/web typecheck` exit 0;
`build` exit 0. Screenshots regenerated with tabs visible:
`fb-posts-grid-1440.png` (IG default), `fb-posts-facebook-tab-1440.png` (NEW, FB
tab), `fb-posts-grid-375.png`; the stale segmented-switch shot was removed.

**(2) WEBHOOK AUTO-INGEST (FB) · sha `26ec067`**
The page `feed` webhook now auto-materializes page posts into `social_posts` —
no manual sync needed. `shared/parseFacebookPostEvents` (Zod, pinned against the
REAL `live-feed-events.json` fixtures: `photo`/add w/ message+post_id → publish,
`post`/remove → removal, `reaction`/add → not a post) emits POST-item add|remove
events with a `pageAuthored` flag. `FbPostsService.processFeedEnvelope`: a
page-authored publish upserts an ORGANIC row (getPost backfill for caption/
permalink/thumbnail, `event.message` fallback on a 403) and enqueues interpret via
the ONE shared `POST_INTERPRET_QUEUE`; **dedup is structural** — upsert on
`(channel, externalId)` never duplicates what sync or comment-discovery wrote, and
an `ads` row is promoted to organic (never clobbering a good caption/permalink with
null). A **removal is LOGGED, never silently dropped** — the row + its comment/
decision history are preserved (deleting would orphan Activity). The webhook
processor routes the `facebook_feed` (non-comment) branch here. Oracles:
```
src/facebook/facebook-posts.test.ts          9 passed   (shared parser, red-first)
src/facebook/fb-posts.service.test.ts        16 passed  (+5 processFeedEnvelope, red-first)
src/webhooks/webhook-processor.service.test  26 passed  (+FB-post routing test)
```
API-scoped `tsc` exit 0; `@channels/shared build` (tsc) exit 0.

**Asymmetry (recorded, per directive):** there is NO Instagram post-publish
webhook on the platform — IG stays sync + comment-time discovery only; ONLY
Facebook gains the webhook auto-ingest path. Documented in code
(`processFeedEnvelope` JSDoc) and here.

**NOT verified (these two changes):** DI boot of the new
`WebhookProcessorService → FbPostsService` edge (deploy lane — WebhooksModule
already imports FacebookModule which exports FbPostsService; typed + acyclic); a
LIVE `feed` post webhook delivering an actual publish into a real row (the gate's
live check — the parser is pinned to captured real payloads, but end-to-end
delivery is deploy-time).
