feat(S3): seção de conteúdos da home de campanha inclui os posts do Instagram

Board "Acompanhe de perto" (S1+S2) ganha o feed automático do perfil oficial:
loader irmão src/utilities/socialFeed/instagramFeed.ts (Graph API /media com
children p/ carrossel + username, unstable_cache tag social-feed + revalidate
300, snapshot cru pré-exclusão persistido via SQL direto, refresh de token
automático com retry 1x — refresh endpoint só renova tokens de Instagram
Login, page tokens caem no fail-closed), global SocialFeedSettings estendida
(instagramEnabled/AccessToken/UserId/MaxItems + snapshot hidden; excludedItems
já cobria a plataforma desde a S2). Requisito nº 1 — picker de exclusão no
admin (primeiro componente custom do repo, campo ui + importmap): lista os
posts do snapshot com thumbnail e toggle "Não exibir" que escreve em
excludedItems. Card com coverAspect square (1:1 do rascunho), badge
Instagram, título = legenda com fallback para post sem legenda, clique no
permalink (noopener), link "Seguir no Instagram →" derivado do username.
Os feeds externos mudam para o subfolder socialFeed/ (pin da S2 previa).
Migration 20260818_192948_add_instagram_social_feed_fields (não destrutiva).
Stub IG no webServer (porta dev+2000, helper socialStub.ts) + 3 e2e novos no
describe serial + kill switch estendido + smoke do picker no admin.
Closes #20
This commit is contained in:
Francisco Solla 2026-08-18 17:55:29 -03:00
parent a6c8ece2f5
commit a638a8acae
25 changed files with 15370 additions and 49 deletions

View file

@ -80,7 +80,7 @@ curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
- **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer <secret>`; it is compared to `REVALIDATE_SECRET` with a constant-time check.
- `REVALIDATE_SECRET` must be set in the **production** env (and in your local env if you want to test the endpoint). It is documented in `.env.example`.
- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`, `election-tse` (bust after re-running `pnpm db:seed:tse` so the cached elections-tab reads refresh), `municipality-catalog` (bust after a migration/seed that adds or renames a município so chip indexes and `unstable_cache` loaders refresh), `social-feed` (bust after a direct-DB write to the `social-feed-settings` global — e.g. an admin-less seed of the YouTube feed config; admin edits self-revalidate). Unknown tag → `400`.
- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`, `election-tse` (bust after re-running `pnpm db:seed:tse` so the cached elections-tab reads refresh), `municipality-catalog` (bust after a migration/seed that adds or renames a município so chip indexes and `unstable_cache` loaders refresh), `social-feed` (bust after a direct-DB write to the `social-feed-settings` global — e.g. an admin-less seed of the YouTube/Instagram feed config; admin edits self-revalidate). Unknown tag → `400`.
- **Responses:** `200 { revalidated: true, tag: '...' }` on success; `401` if the secret is missing/wrong; `500` if `REVALIDATE_SECRET` is not configured on the server.
- This POST is the required last step of the post-seed / direct-DB-change runbook.

View file

@ -10,6 +10,14 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**S6** — home de campanha (jorgesolla1313.com.br) sem carrossel no desktop: as seções "Por que essa eleição importa" (3 cards) e "Nossa caminhada" (6 cards) mostram agora todo o conteúdo de uma vez em ≥1024px — grade estática 3 colunas (problem: 3×437px, bandeiras: 3×2 com altura por conteúdo, padding `p-4`), sem auto-avanço, sem chips/indicadores e sem `aria-current`. Mobile (<1024px) 100% preservado: carrossel com auto-avanço ~4s, chips sincronizados, swipe — componente e geometria mobile intocados. Padrão S1 (renderização-irmã com toggle CSS `hidden lg:grid`/`lg:hidden`): grade server-rendered (zero JS no desktop) + carrossel client embrulhado; os dois cards extraídos do `CampaignCarousel` para `CampaignProblemCard`/`CampaignFlagCard` (presentacionais, fonte única do markup, `size='spacious'` só na grade) e o contrato da grade (data-grid + aria-label + 3 colunas) vive num `CampaignCardGrid` local do page. O carrossel ficou byte-equivalente (só re-roteia os cards; unit tests intocados) e ganhou guard de `display:none` no auto-avanço — o timer nunca roda no desktop (chain morto numa subárvore oculta). Geometria: a seção flags recalibrada no desktop (`height: clamp(418px, calc(807.7px - 28.13vw), 520px)`, âncoras medidas em 1024/1280/1440+, respiro ≥30px, `overflow: hidden` para zoom de texto ≥125% não pintar sobre o footer); a seção problem manteve a geometria (respiro ~31px). Zero schema/migration/Consent; mesma copy/imagens/ordem; S1 e o resto da home intocados. Verificação: unit 2318 + int 791 verdes, e2e frontend 11/11 (novo teste de grade desktop com invariantes 3×1/3×2, ausência de regiões/chips/`aria-current`, respiro ≥24 e `scrollHeight ≤ clientHeight` em 1024/1280/1440/1920; teste de carrossel migrado para viewport mobile 390px com auto-avanço + chips; swipe/mobileSpacing intactos), lint/format/typecheck/knip/cycles/build verdes. `/simplify` 3 reviewers: `lg:` órfãos removidos do carrossel, grade extraída em `CampaignCardGrid`, `overflow: hidden` (zoom ≥125%), guard do timer, `document.fonts.ready` + eixo x + pino de clip no e2e, âncoras no comentário do clamp. Débitos: Issue #58 registrada (S6-FOLLOWUP — CLI do Playwright 1.58.2 ignora `--grep`/posicionais e o `test:e2e:affected` roda a suíte completa; `depends: [S6]`); defer com gatilho para os literais de gap da grade (recalibração Penpot do carrossel); descartados contratos de tipo dos cards (armadilha knip), aria-labels duplicados (copy pinada no e2e) e pino de tablet 768–1023. Impl plan: `docs/plans/home-campanha-sem-carrossel-desktop-impl.md`.
**S3** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os posts do Instagram oficial, completando o board do plano-site §4.2 (artigos S1 + YouTube S2 + Instagram). Fonte automática servidor-side: loader irmão `src/utilities/socialFeed/instagramFeed.ts` (os feeds externos ganharam o subfolder `socialFeed/` — o pin da S2 predizia exatamente isso quando a segunda plataforma entrasse; `git mv` do youtubeFeed + imports atualizados): Graph API (`GET /{ig-user-id}/media` com `children{media_url,thumbnail_url}` p/ carrossel + `GET /{ig-user-id}?fields=username`), `unstable_cache` tag `social-feed` + `revalidate: 300`, snapshot cru (`{ username, posts }`, pré-exclusão) persistido via SQL direto (padrão anti-`revalidateTag`-no-cache da S2), exclusões aplicadas na leitura, **refresh de token automático** (refresh-on-failure + retry 1x, token renovado persistido por SQL direto; nota honesta no admin: o refresh endpoint só renova tokens de Instagram Login — page tokens business caem no fail-closed + rotação manual). Global `SocialFeedSettings` ganha `instagramEnabled/AccessToken/UserId/MaxItems` + `instagramFeedSnapshot` (hidden) — `excludedItems` já cobria `platform: 'instagram'` desde a S2, sem migration no mecanismo; migration `20260818_192948_add_instagram_social_feed_fields` não destrutiva (DDL revisado, sem o drift do `migrate:create` da S2). **Picker de exclusão no admin** (requisito nº 1 da intenção — posts de grade): primeiro componente custom do repo (`src/components/admin/InstagramPostExclusionPicker.tsx`, campo `ui` na global, path string + importmap regenerado — o import direto de `@payloadcms/ui` na config quebra o `generate:importmap` no tsx por causa do `.css` do react-image-crop; com path string o scanner resolve sem carregar o módulo); lista os posts do snapshot (thumbnail + legenda + data + ID) com toggle "Não exibir"/"Reexibir" + motivo opcional que escreve em `excludedItems` via `useField`. UI: `CampaignContentCardData.coverAspect` (`'video'` default — S1/S2 intactos; `'square'` 1:1 do rascunho aprovado para IG), badge "Instagram", título = legenda (fallback "Publicação no Instagram" para post sem legenda — entra no automático para exclusão manual), meta = data relativa, clique no `permalink` (nova aba, `noopener`), link do header "Seguir no Instagram →" (copy do rascunho aprovado; username derivado da API — com API fora e sem snapshot o link some junto com os cards, divergência documentada do "YouTube →" que sobrevive por ter o channelId configurado). next.config ganha `https://*.cdninstagram.com/**`. Testes: 16 units novas (parsers com caption null/carrossel/item sem permalink, thumbnail por media_type, eligibility, fetch + refresh-retry com `fetchImpl`); e2e: stub irmão `tests/e2e/instagram-stub.mjs` (porta dev+2000; a derivação da porta virou `tests/helpers/socialStub.ts` — rename do youtubeStub.ts mantendo UMA spelling), 3 testes novos no describe serial (full-state com exclusão + fallback de legenda + aspect 1:1 + link do perfil + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down) + kill switch estendido com IG configurado + smoke do picker no admin (seed do snapshot via REST, toggle, Salvar, verificação do `excludedItems`). Verificação: unit 2351 + int 791 verdes; gate:fast/lint/format/tsc/knip (exit 0 — o erro de load do payload.config segue o ledger P3)/cycles/build verdes; e2e local: describe serial 9/9 + smoke admin 1/1 (rodando os projetos em sequência — em paralelo com `--no-deps` o `seedTestUser` dos dois beforeAll colide no mesmo usuário e quebra o run, artefato da invocação local, não do produto; o CI roda na cadeia de dependências). Detalhe do debug: o clique via CDP do MCP de browser não dispara eventos de input (ambiente do MCP, não do app — `el.click()` e o runner real funcionam).
<<<<<<< HEAD
**S6** — home de campanha (jorgesolla1313.com.br) sem carrossel no desktop: as seções "Por que essa eleição importa" (3 cards) e "Nossa caminhada" (6 cards) mostram agora todo o conteúdo de uma vez em ≥1024px — grade estática 3 colunas (problem: 3×437px, bandeiras: 3×2 com altura por conteúdo, padding `p-4`), sem auto-avanço, sem chips/indicadores e sem `aria-current`. Mobile (<1024px) 100% preservado: carrossel com auto-avanço ~4s, chips sincronizados, swipe — componente e geometria mobile intocados. Padrão S1 (renderização-irmã com toggle CSS `hidden lg:grid`/`lg:hidden`): grade server-rendered (zero JS no desktop) + carrossel client embrulhado; os dois cards extraídos do `CampaignCarousel` para `CampaignProblemCard`/`CampaignFlagCard` (presentacionais, fonte única do markup, `size='spacious'` só na grade) e o contrato da grade (data-grid + aria-label + 3 colunas) vive num `CampaignCardGrid` local do page. O carrossel ficou byte-equivalente (só re-roteia os cards; unit tests intocados) e ganhou guard de `display:none` no auto-avanço — o timer nunca roda no desktop (chain morto numa subárvore oculta). Geometria: a seção flags recalibrada no desktop (`height: clamp(418px, calc(807.7px - 28.13vw), 520px)`, âncoras medidas em 1024/1280/1440+, respiro ≥30px, `overflow: hidden` para zoom de texto ≥125% não pintar sobre o footer); a seção problem manteve a geometria (respiro ~31px). Zero schema/migration/Consent; mesma copy/imagens/ordem; S1 e o resto da home intocados. Verificação: unit 2318 + int 791 verdes, e2e frontend 11/11 (novo teste de grade desktop com invariantes 3×1/3×2, ausência de regiões/chips/`aria-current`, respiro ≥24 e `scrollHeight ≤ clientHeight` em 1024/1280/1440/1920; teste de carrossel migrado para viewport mobile 390px com auto-avanço + chips; swipe/mobileSpacing intactos), lint/format/typecheck/knip/cycles/build verdes. `/simplify` 3 reviewers: `lg:` órfãos removidos do carrossel, grade extraída em `CampaignCardGrid`, `overflow: hidden` (zoom ≥125%), guard do timer, `document.fonts.ready` + eixo x + pino de clip no e2e, âncoras no comentário do clamp. Débitos: Issue #58 registrada (S6-FOLLOWUP — CLI do Playwright 1.58.2 ignora `--grep`/posicionais e o `test:e2e:affected` roda a suíte completa; `depends: [S6]`); defer com gatilho para os literais de gap da grade (recalibração Penpot do carrossel); descartados contratos de tipo dos cards (armadilha knip), aria-labels duplicados (copy pinada no e2e) e pino de tablet 768–1023. Impl plan: `docs/plans/home-campanha-sem-carrossel-desktop-impl.md`.
=======
**S3** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os posts do Instagram oficial, completando o board do plano-site §4.2 (artigos S1 + YouTube S2 + Instagram). Fonte automática servidor-side: loader irmão `src/utilities/socialFeed/instagramFeed.ts` (os feeds externos ganharam o subfolder `socialFeed/` — o pin da S2 predizia exatamente isso quando a segunda plataforma entrasse; `git mv` do youtubeFeed + imports atualizados): Graph API (`GET /{ig-user-id}/media` com `children{media_url,thumbnail_url}` p/ carrossel + `GET /{ig-user-id}?fields=username`), `unstable_cache` tag `social-feed` + `revalidate: 300`, snapshot cru (`{ username, posts }`, pré-exclusão) persistido via SQL direto (padrão anti-`revalidateTag`-no-cache da S2), exclusões aplicadas na leitura, **refresh de token automático** (refresh-on-failure + retry 1x, token renovado persistido por SQL direto; nota honesta no admin: o refresh endpoint só renova tokens de Instagram Login — page tokens business caem no fail-closed + rotação manual). Global `SocialFeedSettings` ganha `instagramEnabled/AccessToken/UserId/MaxItems` + `instagramFeedSnapshot` (hidden) — `excludedItems` já cobria `platform: 'instagram'` desde a S2, sem migration no mecanismo; migration `20260818_192948_add_instagram_social_feed_fields` não destrutiva (DDL revisado, sem o drift do `migrate:create` da S2). **Picker de exclusão no admin** (requisito nº 1 da intenção — posts de grade): primeiro componente custom do repo (`src/components/admin/InstagramPostExclusionPicker.tsx`, campo `ui` na global, path string + importmap regenerado — o import direto de `@payloadcms/ui` na config quebra o `generate:importmap` no tsx por causa do `.css` do react-image-crop; com path string o scanner resolve sem carregar o módulo); lista os posts do snapshot (thumbnail + legenda + data + ID) com toggle "Não exibir"/"Reexibir" + motivo opcional que escreve em `excludedItems` via `useField`. UI: `CampaignContentCardData.coverAspect` (`'video'` default — S1/S2 intactos; `'square'` 1:1 do rascunho aprovado para IG), badge "Instagram", título = legenda (fallback "Publicação no Instagram" para post sem legenda — entra no automático para exclusão manual), meta = data relativa, clique no `permalink` (nova aba, `noopener`), link do header "Seguir no Instagram →" (copy do rascunho aprovado; username derivado da API — com API fora e sem snapshot o link some junto com os cards, divergência documentada do "YouTube →" que sobrevive por ter o channelId configurado). next.config ganha `https://*.cdninstagram.com/**`. Testes: 16 units novas (parsers com caption null/carrossel/item sem permalink, thumbnail por media_type, eligibility, fetch + refresh-retry com `fetchImpl`); e2e: stub irmão `tests/e2e/instagram-stub.mjs` (porta dev+2000; a derivação da porta virou `tests/helpers/socialStub.ts` — rename do youtubeStub.ts mantendo UMA spelling), 3 testes novos no describe serial (full-state com exclusão + fallback de legenda + aspect 1:1 + link do perfil + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down) + kill switch estendido com IG configurado + smoke do picker no admin (seed do snapshot via REST, toggle, Salvar, verificação do `excludedItems`). Verificação: unit 2351 + int 791 verdes; gate:fast/lint/format/tsc/knip (exit 0 — o erro de load do payload.config segue o ledger P3)/cycles/build verdes; e2e local: describe serial 9/9 + smoke admin 1/1 (rodando os projetos em sequência — em paralelo com `--no-deps` o `seedTestUser` dos dois beforeAll colide no mesmo usuário e quebra o run, artefato da invocação local, não do produto; o CI roda na cadeia de dependências). Detalhe do debug: o clique via CDP do MCP de browser não dispara eventos de input (ambiente do MCP, não do app — `el.click()` e o runner real funcionam).
>>>>>>> 7e301c85 (feat(S3): seção de conteúdos da home de campanha inclui os posts do Instagram)
**S2** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (`@JorgeSollaDep`), estendendo o board da S1. Fonte automática servidor-side: global `SocialFeedSettings` nova (grupo `Configurações`, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch `enabled` ("Pausar feed"), toggle `youtubeEnabled`, chave + channel ID, `youtubeMaxItems` (1–5, default 3) e `excludedItems` (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration `20260818_111748_add_social_feed_settings` não destrutiva (editada para conter só o DDL — o `migrate:create` regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader `src/utilities/youtubeFeed.ts` (irmão de `posts.ts`, pinado no spec de convenções): parsers/format puros (`formatYouTubeViews` pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, `fetchImpl`/`YOUTUBE_API_BASE_URL` injetáveis), `unstable_cache` tag `social-feed` + `revalidate: 300` (TTL da entrada, não da página — o bug do S1 era page-level); **fail-closed com snapshot persistido por SQL direto** (`payload.db.drizzle`, sem hooks — `revalidateTag` dentro de `unstable_cache` LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado `CampaignArticleCard` → `CampaignContentCard` (git mv; `external?: boolean` → `<a target="_blank" rel="noopener noreferrer">`); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha `i.ytimg.com` + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (`tests/e2e/youtube-stub.mjs`, webServer do Playwright vira array, porta = dev+1000; `POST /__stub/state` alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (`E2E_PROD=1` — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. `/simplify` 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, `Promise.all` posts+feed no cache frio, comentário do sentinela 0 do merge, helper único `tests/helpers/youtubeStub.ts` para a porta do stub, `waitForHomeSectionState` fundido no `waitForHomeHTML` com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o `validate` do `itemId` lê `data.platform` que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag `social-feed` fora da convenção `global_*` (precedente election-tse/municipality-catalog), remotePattern `localhost/thumbs` incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate).
**S5** — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o `<section>` sai de `bg-white` (idêntico à faixa de números, seções coladas) para `border-y border-(--campaign-line) bg-(--campaign-band)` — banda `#ebe9e9` da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com `border-top/bottom: 1px solid var(--line)`) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções `toHaveCSS` no e2e full-state da seção (fundo `#ebe9e9` exato — pega token quebrado em vez de só "≠ branco", hairline top `1px` com cor `rgb(0 0 0 / 12%)` exata, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega `payload.config.ts`); CI valida.

View file

@ -0,0 +1 @@
**S3** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os posts do Instagram oficial, completando o board do plano-site §4.2 (artigos S1 + YouTube S2 + Instagram). Fonte automática servidor-side: loader irmão `src/utilities/socialFeed/instagramFeed.ts` (os feeds externos ganharam o subfolder `socialFeed/` — o pin da S2 predizia exatamente isso quando a segunda plataforma entrasse; `git mv` do youtubeFeed + imports atualizados): Graph API (`GET /{ig-user-id}/media` com `children{media_url,thumbnail_url}` p/ carrossel + `GET /{ig-user-id}?fields=username`), `unstable_cache` tag `social-feed` + `revalidate: 300`, snapshot cru (`{ username, posts }`, pré-exclusão) persistido via SQL direto (padrão anti-`revalidateTag`-no-cache da S2), exclusões aplicadas na leitura, **refresh de token automático** (refresh-on-failure + retry 1x, token renovado persistido por SQL direto; nota honesta no admin: o refresh endpoint só renova tokens de Instagram Login — page tokens business caem no fail-closed + rotação manual). Global `SocialFeedSettings` ganha `instagramEnabled/AccessToken/UserId/MaxItems` + `instagramFeedSnapshot` (hidden) — `excludedItems` já cobria `platform: 'instagram'` desde a S2, sem migration no mecanismo; migration `20260818_192948_add_instagram_social_feed_fields` não destrutiva (DDL revisado, sem o drift do `migrate:create` da S2). **Picker de exclusão no admin** (requisito nº 1 da intenção — posts de grade): primeiro componente custom do repo (`src/components/admin/InstagramPostExclusionPicker.tsx`, campo `ui` na global, path string + importmap regenerado — o import direto de `@payloadcms/ui` na config quebra o `generate:importmap` no tsx por causa do `.css` do react-image-crop; com path string o scanner resolve sem carregar o módulo); lista os posts do snapshot (thumbnail + legenda + data + ID) com toggle "Não exibir"/"Reexibir" + motivo opcional que escreve em `excludedItems` via `useField`. UI: `CampaignContentCardData.coverAspect` (`'video'` default — S1/S2 intactos; `'square'` 1:1 do rascunho aprovado para IG), badge "Instagram", título = legenda (fallback "Publicação no Instagram" para post sem legenda — entra no automático para exclusão manual), meta = data relativa, clique no `permalink` (nova aba, `noopener`), link do header "Seguir no Instagram →" (copy do rascunho aprovado; username derivado da API — com API fora e sem snapshot o link some junto com os cards, divergência documentada do "YouTube →" que sobrevive por ter o channelId configurado). next.config ganha `https://*.cdninstagram.com/**`. Testes: 16 units novas (parsers com caption null/carrossel/item sem permalink, thumbnail por media_type, eligibility, fetch + refresh-retry com `fetchImpl`); e2e: stub irmão `tests/e2e/instagram-stub.mjs` (porta dev+2000; a derivação da porta virou `tests/helpers/socialStub.ts` — rename do youtubeStub.ts mantendo UMA spelling), 3 testes novos no describe serial (full-state com exclusão + fallback de legenda + aspect 1:1 + link do perfil + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down) + kill switch estendido com IG configurado + smoke do picker no admin (seed do snapshot via REST, toggle, Salvar, verificação do `excludedItems`). Verificação: unit 2351 + int 791 verdes; gate:fast/lint/format/tsc/knip (exit 0 — o erro de load do payload.config segue o ledger P3)/cycles/build verdes; e2e local: describe serial 9/9 + smoke admin 1/1 (rodando os projetos em sequência — em paralelo com `--no-deps` o `seedTestUser` dos dois beforeAll colide no mesmo usuário e quebra o run, artefato da invocação local, não do produto; o CI roda na cadeia de dependências). Detalhe do debug: o clique via CDP do MCP de browser não dispara eventos de input (ambiente do MCP, não do app — `el.click()` e o runner real funcionam).

View file

@ -0,0 +1,119 @@
# Impl: S3 — Seção de conteúdos na home de campanha — Instagram
Status: aprovado
Atualizado em: 2026-08-18
Issue: #20
Intenção: docs/plans/secao-conteudos-home-instagram.md
Appetite restante: ~1–2 dias eng (herdado)
## Leitura da intenção
- **Outcome:** a seção "Acompanhe de perto" (S1 artigos + S2 YouTube) passa a incluir os posts recentes do Instagram oficial (`@depjorgesolla`) via leitura servidor-side da Instagram Graph API (token long-lived com refresh, perfil Business/Creator) com cache — cards 1:1 com badge "Instagram", legenda (título) e data; clique abre o post **na plataforma** (nova aba, `noopener`); **exclusão por item é o requisito nº 1** (posts de grade que não são conteúdo real de campanha) e a interface de exclusão (admin vê os últimos posts com thumbnail + marca "não exibir") é parte desta fatia; fail-closed com último snapshot; sem credencial IG → cards IG simplesmente não aparecem (a seção vive com artigos + YouTube); kill switch `enabled` vale para o board como um todo; mobile segue o carrossel 1/tela.
- **O que NÃO negociar:** sem embed/script do Instagram no load (LCP); nenhuma chave no cliente; clique na plataforma (sem lightbox); exclusão por item (o bento pula para o próximo elegível); fail-closed silencioso; zero mudança em schema de conteúdo público (migrações não destrutivas no máximo); convenções do repo (identificadores em inglês, admin em pt, cache por tag).
- **O que reavaliar:** a S2 já entregou quase toda a mecânica — global `SocialFeedSettings` com `excludedItems` já tipado para `instagram`, loader irmão com snapshot persistido via SQL direto, card generalizado com `external`, merge por recência na seção. A S3 **não duplica** nada disso: estende a global (campos IG + snapshot IG + picker de exclusão), cria o loader irmão `instagramFeed.ts` e o **picker de exclusão no admin** (única superfície nova de verdade). O rascunho UI aprovado desenha o card IG **1:1** (`aspect-square`) — o `CampaignContentCard` atual é `aspect-video` hardcoded → o card ganha variante de capa (Decisão 3). O plano-site §4.2 já nomeia a config (token + user ID na global `SocialFeedSettings`) e o "refresh automático ~60 dias" (Decisão 2).
## Abordagem recomendada
```mermaid
flowchart LR
S[CampaignContentSection<br/>server] --> P[getVisiblePosts<br/>tag posts]
S --> Y[getYouTubeFeed<br/>tag social-feed]
S --> I[getInstagramFeed<br/>tag social-feed<br/>revalidate 300]
I --> G[global social-feed-settings<br/>admin-only read]
I --> F[fetch Graph API<br/>user + media]
F -->|200| W[(snapshot IG<br/>gravado no global)]
F -->|falha token| R[refresh + retry 1x]
R -->|novo token| F
R -->|falha| SNAP[(lê snapshot IG<br/>se existir)]
I -->|null sem config| N[(sem cards IG)]
I -->|pós-exclusão| M[merge por recência<br/>artigos + YT + IG]
M --> B[Bento desktop<br/>1 grande + 4 pequenos]
M --> C[CampaignContentCarousel<br/>client · <md]
B --> CARD[CampaignContentCard<br/>coverAspect square p/ IG]
C --> CARD
G -.picker admin.-> X[InstagramPostExclusionPicker<br/>marca excludedItems]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — um loader irmão `getInstagramFeed()` em `src/utilities/instagramFeed.ts` (mesmo esqueleto do `youtubeFeed.ts`: parsers/format puros + fetch da Graph API + `unstable_cache` tag `social-feed` + `revalidate: 300` + snapshot cru persistido por SQL direto, exclusões aplicadas na leitura), campos IG na global existente `social-feed-settings` (sem global nova), card IG com `coverAspect: 'square'` (1:1 do rascunho), e o picker de exclusão como **primeiro componente custom do admin do repo** (`src/components/admin/InstagramPostExclusionPicker.tsx`, client, attach num campo `ui` da global — lista os posts do snapshot com toggle que escreve em `excludedItems`).
**Rejeitadas:** B) global/loader separado por plataforma (`instagramSettings` própria) — viola o plano-site §4.2 que nomeia UMA global `SocialFeedSettings` e espalha o mecanismo de exclusão que a S2 já unificou; C) embed de feed do Instagram (script da plataforma) — proibido pelo plano-site §4.2 (LCP do mobile) e pela intenção.
### Componentes / mudanças
- **Global `SocialFeedSettings`** (`src/globals/SocialFeedSettings.ts`, estender): `instagramEnabled` (checkbox "Instagram ativo", default true), `instagramAccessToken` (text "Token de acesso (Instagram Graph API)", admin.description com nota do tipo de token + refresh), `instagramUserId` (text "ID do usuário (conta Business/Creator)"), `instagramMaxItems` (number "Máximo de posts", default 3, min 1, max 5), `instagramFeedSnapshot` (json, `admin.hidden: true`), `instagramExclusionPicker` (campo `ui` com `admin.components.Field` → picker client; sem valor persistido). `excludedItems` já cobre `platform: 'instagram'` — sem migration para o mecanismo de exclusão. **Migration:** `pnpm migrate:create add_instagram_social_feed_fields` (ALTER TABLE ADD COLUMN, não destrutiva; revisar o DDL gerado — o `migrate:create` da S2 regenerou drift de migrations hand-written sem sidecar JSON, a S2 editou o arquivo para conter só o DDL da feature).
- **`src/utilities/instagramFeed.ts`** (novo, `server-only`): `InstagramPost` (`{ id, caption: string | null, mediaType, permalink, thumbnailUrl?, timestamp }`); puros exportados p/ unit: `parseInstagramMediaResponse` (item sem `permalink` é dropado — não dá para abrir na plataforma; `caption` null é mantido — post de grade não tem legenda e deve aparecer para exclusão manual; carrossel resolve thumbnail do primeiro `children`), `pickInstagramThumbnail` (IMAGE → `media_url`; VIDEO/REEL → `thumbnail_url`; CAROUSEL_ALBUM → 1º child com `media_url ?? thumbnail_url`; sem thumbnail → undefined e o card renderiza sem capa — já suportado), `eligibleInstagramPosts(posts, excludedIds, maxItems)`, `loadInstagramFeed({ accessToken, userId, maxResults, fetchImpl, baseUrl })` — `GET /{ig-user-id}/media?fields=id,caption,media_type,media_url,permalink,thumbnail_url,timestamp,children{media_url,thumbnail_url}&limit&access_token` + `GET /{ig-user-id}?fields=username`; em falha de autenticação tenta **refresh + retry 1x** (Decisão 2) e retorna `{ posts, username, refreshedAccessToken? }`; throw em não-2xx/rede/JSON inválido; `baseUrl` default `https://graph.instagram.com`, override `INSTAGRAM_API_BASE_URL`. `getInstagramFeed()` = `unstable_cache` tag `['social-feed']` + `revalidate: 300` — lê a global (Local API), `null` quando desconfigurado/desligado, grava `instagramFeedSnapshot` (`{ username, posts }` cru, pré-exclusão) em 200, persiste token renovado via SQL direto (mesmo padrão do snapshot da S2), lê o snapshot + aplica exclusões quando o fetch falha; sem snapshot → `{ username: null, posts: [] }`.
- **`CampaignContentCard`** (`src/components/CampaignContentCard.tsx`, estender): `CampaignContentCardData` ganha `coverAspect?: 'video' | 'square'` (default `'video'` — S1/S2 intactos); IG passa `coverAspect: 'square'` (1:1 do rascunho); nada mais muda.
- **`CampaignContentSection`**: mescla `getVisiblePosts()` + `getYouTubeFeed()` + `getInstagramFeed()` por recência, fatia 5, `[featured, ...rest]`; card IG: badge "Instagram", título = `caption` (fallback "Publicação no Instagram" quando null — post de grade sem legenda precisa aparecer para exclusão manual), meta = `formatRelativePostDate(timestamp)` (só data — o rascunho não mostra contador para IG), `external: true`, href = `permalink` da API; link do header "Seguir no Instagram →" (`https://www.instagram.com/<username>`, nova aba) renderizado quando o feed retorna `username` — com API fora e sem snapshot o link some junto com os cards (username é derivado da API, não configurado — Decisão 5).
- **`src/components/admin/InstagramPostExclusionPicker.tsx`** (novo, `'use client'`): lista os posts do `instagramFeedSnapshot` (lido via `useAllFormFields`) com thumbnail (`<img>` — admin não passa pelo optimizer), legenda truncada, data relativa e toggle "Não exibir" que adiciona/remove `{ platform: 'instagram', itemId: id, reason? }` em `excludedItems` (via `useField({ path: 'excludedItems' })` + `setModified(true)`); estado vazio honesto ("Nenhum post recente — configure o token e o ID; os posts aparecem aqui após a primeira sincronização"); razão como campo de texto inline opcional. Registrado no importmap (`pnpm generate:importmap` — primeiro componente custom do repo).
- **`next.config.mjs`**: `images.remotePatterns` ganha `https://*.cdninstagram.com/**` (URLs de mídia da Graph API; o pattern `localhost/thumbs` da S2 já cobre o stub).
- **`playwright.config.ts`**: `webServer` ganha mais um entry — `tests/e2e/instagram-stub.mjs` (porta = dev + 2000; ver Decisão 6) + env `INSTAGRAM_API_BASE_URL` no entry do dev server.
- **`tests/helpers/youtubeStub.ts` → `tests/helpers/socialStub.ts`** (rename + extensão): `youtubeStubUrlFor(baseURL)` (+1000) e `instagramStubUrlFor(baseURL)` (+2000) — a derivação da porta continua tendo UMA spelling (a S2 registrou o "+1000 único" como P2 absorvido; com duas plataformas o módulo é o dono, não um twin).
- **Migration:** `add_instagram_social_feed_fields` (não destrutiva). **Access/Consent:** nenhum consent (metadados públicos de fonte externa, como posts/YouTube); global permanece read/update admin-only (token nunca vaza via REST).
- **UI:** Impeccable B — estende a seção S1/S2 com cards IG (mesma estrutura, badge "Instagram"); shape → craft → critique → polish no navegador (desktop + 390px).
### Dados → forma (se aplicável)
- Forma: **nada derivado da campanha** (restrição da intenção) — legenda (título), data relativa (`formatRelativePostDate`, paridade S1/S2) e thumbnail 1:1; sem contadores (IG não entrega views de graça no media edge; o rascunho não mostra). Rejeitadas: `media_url` do vídeo/reel como capa (entrega o arquivo mp4 ao optimizer — thumbnail_url é o formato certo), contagem de likes via extra endpoint (métrica inventada / custo de API sem aceite).
## Decisões de engenharia
1. **Loader irmão do youtubeFeed.**
Opções: A) `src/utilities/instagramFeed.ts` espelhando `youtubeFeed.ts` (puros + fetch + cache + snapshot) | B) generalizar um `socialFeed.ts` único com adapter por plataforma | C) fetch inline no componente.
Recomendação: A — as duas APIs têm contratos diferentes o bastante (URLs, campos, thumbnails, refresh) que um adapter único viraria camada de indireção sem volatilidade real (rabbit hole Teqo); o "irmão" é o precedente declarado pela S2 ("loader irmão de posts.ts"). O conhecimento compartilhado (exclusão, snapshot, kill switch) já está na global + nos helpers, não precisa de classe.
Alternativas rejeitadas: B indireção sem call sites suficientes; C quebra cache/fail-closed e mistura fetch na UI.
2. **Refresh de token automático.**
Opções: A) refresh-on-failure: no erro de autenticação do `/media`, uma chamada `GET /refresh_access_token?grant_type=ig_refresh_token&access_token=…`, persiste o token renovado (SQL direto, padrão snapshot) e retry 1x | B) refresh a cada fetch bem-sucedido (token sempre jovem) | C) sem refresh — rotação manual do admin.
Recomendação: A — satisfaz "token long-lived com refresh automático ~60 dias" do plano-site sem painel de OAuth (corte do rabbit hole da intenção) e sem custo por miss; a falha de refresh cai no fail-closed (snapshot) intacto. Nota honesta no campo do admin: o refresh endpoint da Graph API só renova tokens emitidos via **Instagram Login** (não os page tokens de Facebook Login) — para token business, o refresh falha → snapshot + rotação manual (documentado; setup da Meta é da assessoria, fora do escopo).
Alternativas rejeitadas: B dobra chamadas + 1 write de DB por miss (5 min) sem ganho de correção (o refresh-on-failure cobre o caso real: token expirou); C contradiz o plano-site.
3. **Formato do card IG (1:1).**
Opções: A) `coverAspect?: 'video' | 'square'` no `CampaignContentCardData` (default `'video'`) | B) card IG sempre 16:9 (uniformidade com YT) | C) componente de card separado para IG.
Recomendação: A — o rascunho UI aprovado desenha o IG 1:1 (`aspect-square`, "post 1:1"); o default `'video'` preserva S1/S2 byte a byte; um campo opcional no data é mais barato que um segundo card (aceite da S1: card único por fonte). O grande do bento pode cair num IG (merge por recência) — um quadrado no slot grande é o desenho do rascunho, sem regra especial.
Alternativas rejeitadas: B trai o rascunho aprovado; C duplica o card (viola o aceite da S1).
4. **Picker de exclusão no admin (requisito nº 1 da intenção).**
Opções: A) campo `ui` na global com componente client custom (lista do snapshot + toggle em `excludedItems`) | B) manter o array `excludedItems` cru (admin copia/cola IDs) | C) campo por plataforma (`instagramExcludedIds`) com picker próprio.
Recomendação: A — a intenção decide explicitamente "o admin vê a lista dos últimos posts (thumbnail + ID) e marca os que não devem aparecer; a interface de exclusão é parte desta fatia" (marcar por ID puro é inviável para a assessoria); o snapshot já é persistido pelo loader (a S2 deixou o mecanismo pronto — `excludedItems` já tem `platform: 'instagram'`), então o picker é leitura do snapshot + escrita no array, sem migration e sem endpoint novo.
Alternativas rejeitadas: B falha o aceite; C espalha o mecanismo que a S2 unificou e força migration.
→ Risco: é o primeiro `admin.components` do repo; se `type: 'ui'` não suportar `admin.components.Field` nesta versão (3.82), fallback documentado: campo `json` `admin.hidden: false` com o mesmo componente attachado — verificar no Riscos.
5. **Link do header "Seguir no Instagram →".**
Opções: A) derivar o username da API (`GET /{ig-user-id}?fields=username`, 1 chamada por miss, cacheado) e renderizar o link só quando o feed devolve username | B) campo `instagramUsername` na config | C) sem link de perfil até a S4.
Recomendação: A — o rascunho UI E o wireframe usam exatamente "Seguir no Instagram →" (copy aprovada; nota: a S2 usou "YouTube →" curto — o IG segue a copy do rascunho, divergência de copy documentada); derivar o username evita mais um campo que a assessoria preenche; com API fora e sem snapshot o link some junto com os cards (sem username não há URL honesta — divergência menor do comportamento da S2, onde o link "YouTube →" sobrevive à falha porque o channelId é configurado).
Alternativas rejeitadas: B campo redundante com o dado que a API entrega; C deixa o perfil sem porta de entrada quando os cards somem (anti-goal).
6. **Test seam do fetch IG.**
Opções: A) stub HTTP node irmão (`tests/e2e/instagram-stub.mjs`, porta dev+2000) + `INSTAGRAM_API_BASE_URL` no webServer + `fetchImpl` nas units | B) generalizar o stub da S2 num processo único multi-plataforma | C) interceptação do Playwright no browser.
Recomendação: A — espelha o precedente aprovado da S2 (stub node sem dependências: `/media`, `/user`, `/__stub/health`, `/__stub/state` ok|fail, thumbnails locais em `/thumbs/<id>.jpg` já cobertas pelo remotePattern localhost); o offset +2000 não colide com o +1000 do YouTube nem com dev servers (3100–4099).
Alternativas rejeitadas: B acopla as fixtures das duas plataformas num processo só sem ganho; C não alcança fetch server-side (mesma lição da S2).
→ A derivação da porta vive UMA vez: `tests/helpers/socialStub.ts` (rename de `youtubeStub.ts`, Decisão 7 da S2 mantida).
## Fases verificáveis
1. **Schema + loader** (~metade do appetite): campos IG na global + `pnpm migrate:create add_instagram_social_feed_fields` (revisar DDL gerado — precedente S2 de drift) + `pnpm migrate` local + `pnpm generate:types`; `instagramFeed.ts` (puros + fetch + refresh + cache + snapshot); unit tests (`parseInstagramMediaResponse` com caption null/carrossel/item sem permalink, `pickInstagramThumbnail` por media_type, `eligibleInstagramPosts`, `loadInstagramFeed` com `fetchImpl` fake cobrindo ok/falha/malformado/refresh-retry). Gates parciais: tsc + unit.
2. **UI + admin picker** (restante): `coverAspect` no card; merge IG na seção + link "Seguir no Instagram →"; `next.config` (`*.cdninstagram.com`); `InstagramPostExclusionPicker` + importmap; estender o spec de convenções (pin `instagramFeed.ts`). Verificação manual no admin (a global com snapshot mostra o picker; toggle grava `excludedItems`).
3. **e2e + gates finais**: stub IG + helper de porta (`socialStub.ts`) + webServer; no describe serial da seção: (a) full-state IG — settings + stub ok + 1 artigo + 4 posts (o mais novo EXCLUÍDO → o grande pula para o elegível), asserts: badge "Instagram", grande = post elegível mais recente com href = permalink + `target=_blank` + `rel=noopener`, meta "há X", título = legenda E fallback "Publicação no Instagram" no post sem legenda (post de grade entra no automático — aceite), excluído ausente, "Seguir no Instagram →" com href `instagram.com/<username>` + target blank, carrossel mobile 1/tela com IG; (b) fail-closed sem snapshot — stub fail → cards E link IG ausentes, seção viva com artigo; (c) snapshot — stub ok → cards; stub fail + settings edit (maxItems 2, snapshot omitido) → cards persistem re-filtrados; (d) kill switch — estender o teste existente com IG configurado (`enabled=false` → cards E link IG somem, artigos vivem); (e) smoke do picker no `admin.e2e.spec.ts` (abre a global, vê os posts do snapshot, marca um, salva, verifica `excludedItems` via REST). Cleanup: reset da global (incl. campos IG) + stub ok + revalidate + polling positivo (padrão S2). Polish no navegador (desktop + 390px). Gates: `pnpm gate:fast`, `pnpm test`, `pnpm build` local, e2e dev (`pnpm test:e2e:affected`? — e2e roda uma vez no CI; local opcional) + changelog `docs/changelog/2026-08-18-s3.md` + `pnpm changelog:build`; AGENTS.md (allowlist `social-feed` já documentada — ajustar a menção "YouTube" para o board YT+IG).
## Rabbit holes / Não escopo (engenharia)
- Não paginar o feed IG (1ª página basta; se o admin excluir mais que o headroom, o board mostra menos cards IG — documentado no campo `instagramMaxItems`).
- Não buscar stories, DMs, estatísticas, hashtags, outras contas, mix por tag (pendência §5 do plano-site é decisão da assessoria — o corte é A + exclusão por item, já decidido na intenção).
- Não tocar no bug #40 (validação do `itemId` com `data.platform` sempre undefined) — débito registrado da S2, fora do escopo; o picker escreve IDs válidos de qualquer forma.
- Não criar global/endpoint/rota nova além do componente admin; sem lightbox/embed; sem player inline.
- Não mudar a API de `getVisiblePosts` nem o merge da S2 (artigos+YT intactos quando IG off).
## Riscos e mitigação
- **Primeiro componente admin custom do repo.** `type: 'ui'` + `admin.components.Field` é o caminho canônico do Payload 3; se a versão 3.82 reclamar, fallback: campo `json` com o mesmo componente (o importmap é regenerado de qualquer forma — `pnpm generate:importmap`); validação cedo na fase 2 com o smoke e2e do admin.
- **Token business vs IG Login (refresh).** O refresh endpoint só renova tokens de Instagram Login; para page token business, o refresh falha → snapshot (fail-closed) e o admin rotaciona manualmente — nota no `admin.description` do campo + risco anotado (setup Meta é da assessoria).
- **CDN do Instagram no optimizer.** `scontent*.cdninstagram.com` é wildcard e pode trocar de host — remotePattern `https://*.cdninstagram.com/**` cobre os hosts atuais; se a Meta mudar o host, é 1 linha no `next.config` (anotado no campo admin não é necessário — risco de produção baixo, impacto visual limitado).
- **e2e serial do describe cresce.** 4 testes novos + extensão do kill switch; orçamento 12s de polling (padrão S2) mantido; reset da global no beforeAll inclui os campos IG (senão o estado vazio deixa de ser determinístico).
- **Escrever o token renovado em render.** 1 UPDATE por refresh (raro — só em falha de autenticação), SQL direto sem hooks (mesmo padrão/precondição do snapshot da S2).
- **Home dependente de mais um fetch.** 2 chamadas IG por miss (user + media) ≈ 24/h no pior caso — folga enorme contra o rate limit de 200/h; cache `revalidate: 300` igual ao YT.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (cards 1:1 com badge/legenda/data, clique na plataforma com noopener, exclusão por item com picker no admin, fail-closed com snapshot, kill switch, sem credencial → nada de IG, mobile 1/tela, link "Seguir no Instagram →")
- [ ] Invariantes AGENTS/engineering-standards (migration não destrutiva; sem consent novo; identificadores em inglês; copy/admin em pt; cache por tag `social-feed`; access admin-only na config com segredo; revalidação via `afterChange` da global já cobre)
- [ ] Testes de domínio previstos: unit (parsers, thumbnail por media_type, eligibility, fetch + refresh com fetchImpl) + e2e (full-state IG com exclusão e fallback de legenda, fail-closed sem snapshot, snapshot persistente, kill switch estendido, smoke do picker no admin)

View file

@ -10,9 +10,13 @@ const nextConfig = {
remotePatterns: [
new URL(`${allowedImageHost}/**`),
// YouTube video thumbnails (campaign home content board, S2) come from
// the platform's CDN — see `src/utilities/youtubeFeed.ts`.
// the platform's CDN — see `src/utilities/socialFeed/youtubeFeed.ts`.
new URL('https://i.ytimg.com/**'),
// e2e-only: the YouTube stub (tests/e2e/youtube-stub.mjs) serves the
// Instagram media thumbnails (campaign home content board, S3) come from
// the Graph API's CDN (hosts vary per region) —
// see `src/utilities/socialFeed/instagramFeed.ts`.
new URL('https://*.cdninstagram.com/**'),
// e2e-only: the YouTube/Instagram stubs (tests/e2e/*-stub.mjs) serve the
// fixture thumbnails locally so specs never touch the real network.
{ protocol: 'http', hostname: 'localhost', pathname: '/thumbs/**' },
],

View file

@ -6,7 +6,7 @@ import {
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME,
GOOGLE_CALENDAR_TEST_KEY,
} from './tests/helpers/googleCalendarTestKey'
import { youtubeStubUrlFor } from './tests/helpers/youtubeStub'
import { instagramStubUrlFor, youtubeStubUrlFor } from './tests/helpers/socialStub'
/**
* e2e tests boot a real dev server that seeds/deletes records, so they must run
@ -28,14 +28,17 @@ assertTestDatabase(process.env.DATABASE_URL)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const webServerPort = new URL(baseURL).port || (baseURL.startsWith('https:') ? '443' : '80')
/*
* Port of the YouTube Data API stub the content board (S2) fetches in e2e.
* dev ports live in 3100..4099 (worktree slots, capped at 999), so +1000
* never collides with another worktree's dev server; CI stays on 4000. The
* derivation lives once in `tests/e2e/helpers/youtubeStub.ts` (the spec uses
* it to flip the stub state).
* Ports of the social-feed stubs the content board fetches in e2e (S2
* YouTube, S3 Instagram). dev ports live in 3100..4099 (worktree slots,
* capped at 999), so +1000 (YouTube) and +2000 (Instagram) never collide
* with another worktree's dev server nor with each other; CI stays on 4000
* and 5000. The derivation lives once in `tests/helpers/socialStub.ts` (the
* spec uses it to flip the stub states).
*/
const youtubeStubUrl = youtubeStubUrlFor(baseURL)
const youtubeStubPort = new URL(youtubeStubUrl).port
const instagramStubUrl = instagramStubUrlFor(baseURL)
const instagramStubPort = new URL(instagramStubUrl).port
const workersOverride = process.env.PLAYWRIGHT_WORKERS
const workers = workersOverride === undefined ? 2 : Number(workersOverride)
if (!Number.isInteger(workers) || workers < 2) {
@ -188,9 +191,11 @@ export default defineConfig({
// Lets e2e specs bust the `posts` tag after direct REST deletes (cleanup),
// mirroring the documented post-seed runbook against the deployed site.
REVALIDATE_SECRET: process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret',
// The content board (S2) fetches the YouTube Data API through the local
// stub below instead of the real network (deterministic fixtures).
// The content board (S2/S3) fetches the YouTube Data API and the
// Instagram Graph API through the local stubs below instead of the
// real network (deterministic fixtures).
YOUTUBE_API_BASE_URL: youtubeStubUrl,
INSTAGRAM_API_BASE_URL: instagramStubUrl,
},
},
{
@ -203,5 +208,15 @@ export default defineConfig({
YOUTUBE_STUB_PORT: String(youtubeStubPort),
},
},
{
/* Deterministic Instagram Graph API responses for the content board. */
command: `node tests/e2e/instagram-stub.mjs`,
reuseExistingServer: false,
url: `${instagramStubUrl}/__stub/health`,
timeout: 30_000,
env: {
INSTAGRAM_STUB_PORT: String(instagramStubPort),
},
},
],
})

View file

@ -34,6 +34,7 @@ import { ExportPreview as ExportPreview_cdf7e044479f899a31f804427d568b36 } from
import { ExportSaveButton as ExportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { ImportPreview as ImportPreview_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { InstagramPostExclusionPicker as InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0 } from '../../../components/admin/InstagramPostExclusionPicker'
import { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
@ -74,6 +75,7 @@ export const importMap = {
"@payloadcms/plugin-import-export/rsc#ExportSaveButton": ExportSaveButton_cdf7e044479f899a31f804427d568b36,
"@payloadcms/plugin-import-export/rsc#ImportPreview": ImportPreview_cdf7e044479f899a31f804427d568b36,
"@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36,
"./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0,
"@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}

View file

@ -10,6 +10,8 @@ export type CampaignContentCardData = {
coverUrl?: string
coverAlt?: string
subtitle?: string
/** Cover ratio: 16:9 for articles/videos, 1:1 for Instagram posts. */
coverAspect?: 'video' | 'square'
/** External target (e.g. a YouTube video): opens in a new tab with noopener. */
external?: boolean
}
@ -33,7 +35,11 @@ const cardClassName = (featured: boolean) =>
export const CampaignContentCard = ({ card, featured = false }: CampaignContentCardProps) => {
const inner = (
<>
<div className="relative aspect-video w-full shrink-0 overflow-hidden rounded-lg bg-(--campaign-band)">
<div
className={`relative w-full shrink-0 overflow-hidden rounded-lg bg-(--campaign-band) ${
card.coverAspect === 'square' ? 'aspect-square' : 'aspect-video'
}`}
>
{card.coverUrl ? (
<Image
src={card.coverUrl}

View file

@ -8,7 +8,12 @@ import {
getVisiblePosts,
POST_TYPE_BADGE_LABELS,
} from '@/utilities/posts'
import { formatYouTubeViews, getYouTubeFeed, type YouTubeVideo } from '@/utilities/youtubeFeed'
import { getInstagramFeed, type InstagramPost } from '@/utilities/socialFeed/instagramFeed'
import {
formatYouTubeViews,
getYouTubeFeed,
type YouTubeVideo,
} from '@/utilities/socialFeed/youtubeFeed'
import Link from 'next/link'
const CONTENT_SECTION_LIMIT = 5
@ -69,23 +74,44 @@ const toVideoCardData = (video: YouTubeVideo): DatedCard => ({
date: dateTimeOf(video.publishedAt),
})
const toInstagramCardData = (post: InstagramPost): DatedCard => ({
card: {
id: `ig:${post.id}`,
href: post.permalink,
title: post.caption ?? 'Publicação no Instagram',
badgeLabel: 'Instagram',
meta: formatRelativePostDate(post.timestamp) ?? '',
coverAspect: 'square',
...(post.thumbnailUrl
? { coverUrl: post.thumbnailUrl, coverAlt: post.caption ?? 'Publicação no Instagram' }
: {}),
external: true,
},
date: dateTimeOf(post.timestamp),
})
/**
* Campaign home content board: the 5 most recent items across the visible
* posts (S1) and the eligible YouTube feed (S2), newest first, as a 1+4 bento
* on desktop and a one-per-screen carousel on mobile. Hides entirely when
* nothing is visible (`isPostVisible` fail-closed — hidden electoral tags
* never leak) and never breaks when the feed API is down (snapshot or no
* cards). With the feed unconfigured it degrades to the S1 articles-only
* behavior.
* posts (S1), the eligible YouTube feed (S2) and the eligible Instagram feed
* (S3), newest first, as a 1+4 bento on desktop and a one-per-screen carousel
* on mobile. Hides entirely when nothing is visible (`isPostVisible`
* fail-closed — hidden electoral tags never leak) and never breaks when a
* feed API is down (snapshot or no cards). With the feeds unconfigured it
* degrades to the S1 articles-only behavior.
*/
export const CampaignContentSection = async () => {
const [visiblePosts, feed] = await Promise.all([getVisiblePosts(), getYouTubeFeed()])
const [visiblePosts, youtubeFeed, instagramFeed] = await Promise.all([
getVisiblePosts(),
getYouTubeFeed(),
getInstagramFeed(),
])
const articleCards = visiblePosts
.map(toArticleCardData)
.filter((entry): entry is DatedCard => entry !== null)
const videoCards = (feed?.videos ?? []).map(toVideoCardData)
const videoCards = (youtubeFeed?.videos ?? []).map(toVideoCardData)
const instagramCards = (instagramFeed?.posts ?? []).map(toInstagramCardData)
const cards = [...articleCards, ...videoCards]
const cards = [...articleCards, ...videoCards, ...instagramCards]
.sort(byRecencyDesc)
.slice(0, CONTENT_SECTION_LIMIT)
.map(({ card }) => card)
@ -120,9 +146,19 @@ export const CampaignContentSection = async () => {
<Link href="/artigos" className={sectionHeaderLinkClassName}>
Ver artigos →
</Link>
{feed ? (
{instagramFeed?.username ? (
<a
href={`https://www.youtube.com/channel/${feed.channelId}`}
href={`https://www.instagram.com/${instagramFeed.username}`}
target="_blank"
rel="noopener noreferrer"
className={sectionHeaderLinkClassName}
>
Seguir no Instagram →
</a>
) : null}
{youtubeFeed ? (
<a
href={`https://www.youtube.com/channel/${youtubeFeed.channelId}`}
target="_blank"
rel="noopener noreferrer"
className={sectionHeaderLinkClassName}

View file

@ -0,0 +1,139 @@
'use client'
import { useAllFormFields, useField } from '@payloadcms/ui'
import type { FieldClientComponent } from 'payload'
type InstagramSnapshotPost = {
id: string
caption: string | null
mediaType: string
permalink: string
thumbnailUrl?: string
timestamp: string
}
type InstagramSnapshot = {
username?: string
posts: InstagramSnapshotPost[]
}
type ExcludedItem = {
platform?: string
itemId?: string
reason?: string | null
}
const isInstagramSnapshot = (value: unknown): value is InstagramSnapshot =>
typeof value === 'object' && value !== null && Array.isArray((value as { posts?: unknown }).posts)
/**
* Instagram exclusion picker (S3) — the board's "requisito nº 1": the
* assessoria sees the latest posts of the profile (thumbnail + caption + date,
* from the persisted feed snapshot) and marks the ones that must NOT appear on
* the campaign home (e.g. grid mosaics). Each toggle writes/removes an entry
* in the shared `excludedItems` array (platform `instagram`) — the same
* mechanism YouTube uses, no migration needed. The posts appear here after the
* first successful feed sync (cache of 5 min); while the API is down the
* snapshot keeps the list alive.
*/
export const InstagramPostExclusionPicker: FieldClientComponent = () => {
const [fields] = useAllFormFields()
const { value: excludedItemsValue, setValue } = useField<ExcludedItem[]>({
path: 'excludedItems',
})
const snapshot = fields.instagramFeedSnapshot?.value as unknown
const posts = isInstagramSnapshot(snapshot) ? snapshot.posts : []
const current = Array.isArray(excludedItemsValue) ? excludedItemsValue : []
const isExcluded = (postId: string) =>
current.some((item) => item.platform === 'instagram' && item.itemId === postId)
const reasonOf = (postId: string) =>
current.find((item) => item.platform === 'instagram' && item.itemId === postId)?.reason ?? ''
const toggle = (post: InstagramSnapshotPost) => {
const next = isExcluded(post.id)
? current.filter((item) => !(item.platform === 'instagram' && item.itemId === post.id))
: [...current, { platform: 'instagram', itemId: post.id }]
setValue(next)
}
const setReason = (postId: string, reason: string) => {
const next = current.map((item) =>
item.platform === 'instagram' && item.itemId === postId ? { ...item, reason } : item,
)
setValue(next)
}
if (posts.length === 0) {
return (
<p className="text-sm text-zinc-500">
Nenhum post recente ainda. Com o token e o ID do usuário configurados, os posts aparecem
aqui após a primeira sincronização (a cada 5 minutos).
</p>
)
}
return (
<div className="space-y-2">
<p className="text-sm text-zinc-500">
Posts recentes do perfil (da última sincronização). Marque &quot;Não exibir&quot; nos que
não são conteúdo real de campanha (ex.: grade do feed) — eles somem do board.
</p>
{posts.map((post) => {
const excluded = isExcluded(post.id)
return (
<div
key={post.id}
className={`flex items-center gap-3 rounded-lg border p-2 ${
excluded ? 'border-zinc-300 bg-zinc-100' : 'border-zinc-200'
}`}
>
{post.thumbnailUrl ? (
// Plain img: the admin panel renders media outside next/image.
// eslint-disable-next-line @next/next/no-img-element
<img
src={post.thumbnailUrl}
alt=""
width={56}
height={56}
className="h-14 w-14 shrink-0 rounded object-cover"
/>
) : (
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded bg-zinc-200 text-xs text-zinc-500">
sem capa
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-zinc-900">
{post.caption ?? 'Publicação no Instagram'}
</p>
<p className="text-xs text-zinc-500">
{new Date(post.timestamp).toLocaleDateString('pt-BR')} · {post.id}
</p>
{excluded ? (
<input
defaultValue={reasonOf(post.id)}
onChange={(event) => setReason(post.id, event.target.value)}
placeholder="Motivo (opcional)"
aria-label={`Motivo da exclusão de ${post.caption ?? 'publicação'}`}
className="mt-1 w-full rounded border border-zinc-300 px-2 py-1 text-xs text-zinc-700"
/>
) : null}
</div>
<button
type="button"
onClick={() => toggle(post)}
className={`shrink-0 rounded px-3 py-1.5 text-xs font-bold transition-colors ${
excluded
? 'bg-zinc-200 text-zinc-700 hover:bg-zinc-300'
: 'bg-zinc-900 text-white hover:bg-zinc-700'
}`}
>
{excluded ? 'Reexibir' : 'Não exibir'}
</button>
</div>
)
})}
</div>
)
}

View file

@ -79,6 +79,53 @@ export const SocialFeedSettings: GlobalConfig = {
description: 'Quantos vídeos entram no bento (1 grande + os próximos).',
},
},
{
name: 'instagramEnabled',
type: 'checkbox',
label: 'Instagram ativo',
defaultValue: true,
admin: {
description: 'Desliga só os cards de post do Instagram.',
},
},
{
name: 'instagramAccessToken',
type: 'text',
label: 'Token de acesso (Instagram Graph API)',
admin: {
description:
'Token long-lived da conta Business/Creator vinculada à página do Facebook. Tokens emitidos via Instagram Login são renovados automaticamente; para page tokens, o refresh falha e o token precisa ser trocado aqui manualmente.',
},
},
{
name: 'instagramUserId',
type: 'text',
label: 'ID do usuário (conta Business/Creator)',
admin: {
description: 'ID numérico da conta de negócios do Instagram.',
},
},
{
name: 'instagramMaxItems',
type: 'number',
label: 'Máximo de posts',
defaultValue: 3,
min: 1,
max: 5,
admin: {
description: 'Quantos posts entram no bento (1 grande + os próximos).',
},
},
{
name: 'instagramExclusionPicker',
type: 'ui',
label: 'Instagram — posts recentes',
admin: {
components: {
Field: './components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker',
},
},
},
{
name: 'excludedItems',
type: 'array',
@ -131,5 +178,12 @@ export const SocialFeedSettings: GlobalConfig = {
hidden: true,
},
},
{
name: 'instagramFeedSnapshot',
type: 'json',
admin: {
hidden: true,
},
},
],
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "social_feed_settings" ADD COLUMN "instagram_enabled" boolean DEFAULT true;
ALTER TABLE "social_feed_settings" ADD COLUMN "instagram_access_token" varchar;
ALTER TABLE "social_feed_settings" ADD COLUMN "instagram_user_id" varchar;
ALTER TABLE "social_feed_settings" ADD COLUMN "instagram_max_items" numeric DEFAULT 3;
ALTER TABLE "social_feed_settings" ADD COLUMN "instagram_feed_snapshot" jsonb;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "social_feed_settings" DROP COLUMN "instagram_enabled";
ALTER TABLE "social_feed_settings" DROP COLUMN "instagram_access_token";
ALTER TABLE "social_feed_settings" DROP COLUMN "instagram_user_id";
ALTER TABLE "social_feed_settings" DROP COLUMN "instagram_max_items";
ALTER TABLE "social_feed_settings" DROP COLUMN "instagram_feed_snapshot";`)
}

View file

@ -55,6 +55,7 @@ import * as migration_20260811_205157_add_google_calendar_push_channel from './2
import * as migration_20260811_213648_add_activity_last_mirrored_change_at from './20260811_213648_add_activity_last_mirrored_change_at';
import * as migration_20260811_234822_add_state_deputy_ballot_name from './20260811_234822_add_state_deputy_ballot_name';
import * as migration_20260818_111748_add_social_feed_settings from './20260818_111748_add_social_feed_settings';
import * as migration_20260818_192948_add_instagram_social_feed_fields from './20260818_192948_add_instagram_social_feed_fields';
export const migrations = [
{
@ -340,6 +341,11 @@ export const migrations = [
{
up: migration_20260818_111748_add_social_feed_settings.up,
down: migration_20260818_111748_add_social_feed_settings.down,
name: '20260818_111748_add_social_feed_settings'
name: '20260818_111748_add_social_feed_settings',
},
{
up: migration_20260818_192948_add_instagram_social_feed_fields.up,
down: migration_20260818_192948_add_instagram_social_feed_fields.down,
name: '20260818_192948_add_instagram_social_feed_fields'
},
];

View file

@ -2395,6 +2395,22 @@ export interface SocialFeedSetting {
* Quantos vídeos entram no bento (1 grande + os próximos).
*/
youtubeMaxItems?: number | null;
/**
* Desliga só os cards de post do Instagram.
*/
instagramEnabled?: boolean | null;
/**
* Token long-lived da conta Business/Creator vinculada à página do Facebook. Tokens emitidos via Instagram Login são renovados automaticamente; para page tokens, o refresh falha e o token precisa ser trocado aqui manualmente.
*/
instagramAccessToken?: string | null;
/**
* ID numérico da conta de negócios do Instagram.
*/
instagramUserId?: string | null;
/**
* Quantos posts entram no bento (1 grande + os próximos).
*/
instagramMaxItems?: number | null;
/**
* Conteúdo marcado para NÃO aparecer no board (vídeo errado, transmissão incompleta, post de grade). O board pula para o próximo elegível.
*/
@ -2415,6 +2431,15 @@ export interface SocialFeedSetting {
| number
| boolean
| null;
instagramFeedSnapshot?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
@ -2513,6 +2538,10 @@ export interface SocialFeedSettingsSelect<T extends boolean = true> {
youtubeApiKey?: T;
youtubeChannelId?: T;
youtubeMaxItems?: T;
instagramEnabled?: T;
instagramAccessToken?: T;
instagramUserId?: T;
instagramMaxItems?: T;
excludedItems?:
| T
| {
@ -2522,6 +2551,7 @@ export interface SocialFeedSettingsSelect<T extends boolean = true> {
id?: T;
};
youtubeFeedSnapshot?: T;
instagramFeedSnapshot?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;

View file

@ -0,0 +1,312 @@
import 'server-only'
import type { SocialFeedSetting } from '@/payload-types'
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
import configPromise from '@payload-config'
import { sql } from '@payloadcms/db-postgres'
import { unstable_cache } from 'next/cache'
import { getPayload, type Payload } from 'payload'
const INSTAGRAM_API_BASE_URL = process.env.INSTAGRAM_API_BASE_URL ?? 'https://graph.instagram.com'
export const INSTAGRAM_MAX_RESULTS_CAP = 50
type InstagramMediaType = 'IMAGE' | 'VIDEO' | 'REEL' | 'CAROUSEL_ALBUM'
export type InstagramPost = {
id: string
caption: string | null
mediaType: InstagramMediaType
permalink: string
thumbnailUrl?: string
timestamp: string
}
export type InstagramFeedResult = {
username: string | null
posts: InstagramPost[]
}
export type LoadInstagramFeedArgs = {
accessToken: string
userId: string
maxResults: number
fetchImpl?: (input: string, init?: RequestInit) => Promise<Response>
baseUrl?: string
}
export type LoadInstagramFeedResult = InstagramFeedResult & {
/** Present only when the fetch had to refresh the token mid-flight. */
refreshedAccessToken?: string
}
const MEDIA_FIELDS = [
'id',
'caption',
'media_type',
'media_url',
'permalink',
'thumbnail_url',
'timestamp',
'children{media_url,thumbnail_url}',
].join(',')
/**
* Best thumbnail of a media item, by type: images use the media URL, videos
* and reels the thumbnail (never the mp4), carousels the first child that
* carries either. A missing value reads as undefined and the card renders
* without a cover (already supported) instead of a broken image.
*/
export const pickInstagramThumbnail = (item: {
media_type?: unknown
media_url?: unknown
thumbnail_url?: unknown
children?: unknown
}): string | undefined => {
const urlOf = (value: unknown): string | undefined =>
typeof value === 'string' && value ? value : undefined
const mediaType = item.media_type
if (mediaType === 'IMAGE') return urlOf(item.media_url)
if (mediaType === 'VIDEO' || mediaType === 'REEL') return urlOf(item.thumbnail_url)
if (mediaType === 'CAROUSEL_ALBUM') {
const children = item.children as { data?: unknown } | undefined
if (typeof children === 'object' && children !== null && Array.isArray(children.data)) {
for (const child of children.data) {
if (typeof child !== 'object' || child === null) continue
const rawChild = child as { media_url?: unknown; thumbnail_url?: unknown }
const url = urlOf(rawChild.media_url) ?? urlOf(rawChild.thumbnail_url)
if (url) return url
}
}
}
return undefined
}
/**
* Parses a `/media` response into posts. Throws on a protocol-level violation
* (no `data` array); individual malformed items are skipped, but an item
* without an id, a timestamp or a permalink is dropped — it could not be
* placed in the recency merge nor opened on the platform. A null caption is
* kept: captionless posts (e.g. grid mosaics) still show with a fallback
* title so the assessoria can exclude them item by item.
*/
export const parseInstagramMediaResponse = (json: unknown): InstagramPost[] => {
if (!json || typeof json !== 'object') {
throw new Error('Resposta de mídia do Instagram inválida')
}
const data = (json as { data?: unknown }).data
if (!Array.isArray(data)) {
throw new Error('Resposta de mídia do Instagram sem lista de itens')
}
const posts: InstagramPost[] = []
for (const item of data) {
if (!item || typeof item !== 'object') continue
const rawItem = item as {
id?: unknown
caption?: unknown
media_type?: unknown
permalink?: unknown
timestamp?: unknown
}
if (typeof rawItem.id !== 'string' || !rawItem.id) continue
if (typeof rawItem.permalink !== 'string' || !rawItem.permalink) continue
if (typeof rawItem.timestamp !== 'string' || !rawItem.timestamp) continue
posts.push({
id: rawItem.id,
caption: typeof rawItem.caption === 'string' ? rawItem.caption : null,
mediaType: (rawItem.media_type as InstagramMediaType) ?? 'IMAGE',
permalink: rawItem.permalink,
timestamp: rawItem.timestamp,
thumbnailUrl: pickInstagramThumbnail(rawItem),
})
}
return posts
}
/**
* Keeps only the posts whose ids are not excluded, in API order (newest
* first), capped at `maxItems`. The board skips an excluded post — it never
* counts as "the latest" (grid mosaics get filtered this way).
*/
export const eligibleInstagramPosts = (
posts: InstagramPost[],
excludedIds: string[],
maxItems: number,
): InstagramPost[] => posts.filter((post) => !excludedIds.includes(post.id)).slice(0, maxItems)
const jsonFrom = async (response: Response): Promise<unknown> => response.json()
/**
* Fetches the username and the latest media of a Business/Creator profile:
* `GET /{userId}` for the username, then `GET /{userId}/media` for the posts
* (permalink, caption, thumbnail, timestamp). On any failure it attempts one
* token refresh (`refresh_access_token` — only mints/refreshes Instagram
* Login tokens; page tokens from Facebook Login error out and the caller
* falls back to the snapshot) and retries once. Throws when the retry also
* fails so the cached wrapper can fail closed.
*/
export const loadInstagramFeed = async ({
accessToken,
userId,
maxResults,
fetchImpl = fetch,
baseUrl = INSTAGRAM_API_BASE_URL,
}: LoadInstagramFeedArgs): Promise<LoadInstagramFeedResult> => {
const attempt = async (token: string): Promise<InstagramFeedResult> => {
const userParams = new URLSearchParams({ fields: 'username', access_token: token })
const userResponse = await fetchImpl(`${baseUrl}/${userId}?${userParams}`)
if (!userResponse.ok) {
throw new Error(`Instagram user falhou com status ${userResponse.status}`)
}
const userJson = (await jsonFrom(userResponse)) as { username?: unknown }
const username = typeof userJson.username === 'string' ? userJson.username : null
const mediaParams = new URLSearchParams({
fields: MEDIA_FIELDS,
limit: String(Math.min(Math.max(maxResults, 1), INSTAGRAM_MAX_RESULTS_CAP)),
access_token: token,
})
const mediaResponse = await fetchImpl(`${baseUrl}/${userId}/media?${mediaParams}`)
if (!mediaResponse.ok) {
throw new Error(`Instagram media falhou com status ${mediaResponse.status}`)
}
return { username, posts: parseInstagramMediaResponse(await jsonFrom(mediaResponse)) }
}
try {
return await attempt(accessToken)
} catch {
const refreshParams = new URLSearchParams({
grant_type: 'ig_refresh_token',
access_token: accessToken,
})
const refreshResponse = await fetchImpl(`${baseUrl}/refresh_access_token?${refreshParams}`)
if (!refreshResponse.ok) {
throw new Error(`Instagram token refresh falhou com status ${refreshResponse.status}`)
}
const refreshJson = (await jsonFrom(refreshResponse)) as { access_token?: unknown }
if (typeof refreshJson.access_token !== 'string' || !refreshJson.access_token) {
throw new Error('Instagram token refresh sem novo token')
}
return {
...(await attempt(refreshJson.access_token)),
refreshedAccessToken: refreshJson.access_token,
}
}
}
const excludedInstagramIds = (settings: SocialFeedSetting): string[] =>
(settings.excludedItems ?? [])
.filter((item) => item.platform === 'instagram')
.map((item) => item.itemId)
.filter(Boolean)
const isInstagramSnapshot = (value: unknown): value is { username?: unknown; posts?: unknown } =>
typeof value === 'object' && value !== null
/**
* Persists the raw feed as the global's snapshot column. Deliberately raw SQL
* (`payload.db.drizzle`) instead of `payload.updateGlobal`: this runs INSIDE
* `unstable_cache` during a page render, and Next 15.4 throws when
* `revalidateTag` (the global's `afterChange` hook) fires inside a cached
* function. Raw SQL bypasses hooks; the row is created on first write when the
* admin has not saved the global yet (a serial id is assigned, so no id
* assumption is made).
*
* Precondition: at most one `social_feed_settings` row exists. The UPDATE
* targets the single row and the INSERT only fires when none exists; a
* concurrent double-insert would need two writers racing the very first write
* (the row is created by the first admin save or feed render) — accepted.
*/
const persistInstagramSnapshot = async (
payload: Payload,
snapshot: InstagramFeedResult,
): Promise<void> => {
const database = payload.db.drizzle
const json = JSON.stringify(snapshot)
const updated = await database.execute(sql`
UPDATE "social_feed_settings"
SET "instagram_feed_snapshot" = ${json}::jsonb, "updated_at" = now()
WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1)
`)
if (!updated?.rowCount) {
await database.execute(sql`
INSERT INTO "social_feed_settings" ("instagram_feed_snapshot", "created_at", "updated_at")
VALUES (${json}::jsonb, now(), now())
`)
}
}
/** Persists a refreshed token the same way as the snapshot (raw SQL, no hooks). */
const persistInstagramAccessToken = async (payload: Payload, token: string): Promise<void> => {
await payload.db.drizzle.execute(sql`
UPDATE "social_feed_settings"
SET "instagram_access_token" = ${token}, "updated_at" = now()
WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1)
`)
}
/**
* Cached Instagram feed for the campaign home content board: `null` when the
* feed is off (kill switch, platform toggle or missing credentials), otherwise
* the latest eligible posts plus the profile username. A successful fetch
* persists the RAW list (pre-exclusion) as the global snapshot; a failed fetch
* falls back to that snapshot with the current exclusions applied — so a new
* exclusion works even while the API is down, and the page never breaks.
* Busted by the global's `afterChange` (tag `social-feed`); the per-entry
* `revalidate` keeps new uploads appearing without an admin edit.
*/
export const getInstagramFeed = unstable_cache(
async (): Promise<InstagramFeedResult | null> => {
const payload = await getPayload({ config: configPromise })
const settings = await payload.findGlobal({
slug: 'social-feed-settings',
depth: 0,
})
if (
settings.enabled === false ||
settings.instagramEnabled === false ||
!settings.instagramAccessToken ||
!settings.instagramUserId
) {
return null
}
const maxItems = Math.min(Math.max(settings.instagramMaxItems ?? 3, 1), 5)
const excludedIds = excludedInstagramIds(settings)
try {
const result = await loadInstagramFeed({
accessToken: settings.instagramAccessToken,
userId: settings.instagramUserId,
maxResults: Math.min(maxItems + 10, INSTAGRAM_MAX_RESULTS_CAP),
})
await persistInstagramSnapshot(payload, { username: result.username, posts: result.posts })
if (result.refreshedAccessToken) {
await persistInstagramAccessToken(payload, result.refreshedAccessToken)
}
return {
username: result.username,
posts: eligibleInstagramPosts(result.posts, excludedIds, maxItems),
}
} catch {
const snapshot = settings.instagramFeedSnapshot as unknown
if (!isInstagramSnapshot(snapshot) || !Array.isArray(snapshot.posts)) {
return { username: null, posts: [] }
}
return {
username: typeof snapshot.username === 'string' ? snapshot.username : null,
posts: eligibleInstagramPosts(snapshot.posts, excludedIds, maxItems),
}
}
},
['instagram-feed'],
{ tags: [REVALIDATE_SOCIAL_FEED_TAG], revalidate: 300 },
)

View file

@ -38,4 +38,98 @@ test.describe('Admin Panel', () => {
const editViewArtifact = page.locator('input[name="email"]')
await expect(editViewArtifact).toBeVisible()
})
test('can exclude an Instagram post through the content board picker', async ({
page,
request,
}) => {
// Seed the social-feed global with an Instagram snapshot so the exclusion
// picker (S3) has posts to show. The snapshot is plain data — the picker
// lists it and writes `excludedItems` (the same mechanism YouTube uses).
const login = await request.post(`${baseURL}/api/users/login`, {
data: { email: testUser.email, password: testUser.password },
})
expect(login.ok()).toBeTruthy()
const { token } = await login.json()
const headers = { cookie: `payload-token=${token}` }
const snapshot = {
username: 'depjorgesolla',
posts: [
{
id: 'e2e-ig-muro-1',
caption: 'E2e Post do muro',
mediaType: 'IMAGE',
permalink: 'https://www.instagram.com/p/e2e-ig-muro-1/',
thumbnailUrl: null,
timestamp: '2026-08-18T10:00:00+00:00',
},
{
id: 'e2e-ig-reel-2',
caption: 'E2e Reel da caravana',
mediaType: 'REEL',
permalink: 'https://www.instagram.com/p/e2e-ig-reel-2/',
thumbnailUrl: null,
timestamp: '2026-08-18T09:00:00+00:00',
},
],
}
const seed = await request.post(`${baseURL}/api/globals/social-feed-settings`, {
headers,
data: {
instagramEnabled: true,
instagramAccessToken: 'e2e-ig-token',
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
excludedItems: [],
instagramFeedSnapshot: snapshot,
},
})
expect(seed.ok()).toBeTruthy()
try {
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
await expect(page.getByText('E2e Post do muro')).toBeVisible()
const hideButton = page.getByRole('button', { name: 'Não exibir' }).first()
await expect(hideButton).toBeVisible()
await hideButton.click()
await expect(page.getByRole('button', { name: 'Reexibir' })).toBeVisible()
const saveResponse = page.waitForResponse(
(response) =>
response.url().includes('/api/globals/social-feed-settings') &&
response.request().method() === 'POST',
)
await page.getByRole('button', { name: 'Salvar' }).click()
await saveResponse
const read = await request.get(`${baseURL}/api/globals/social-feed-settings`, { headers })
expect(read.ok()).toBeTruthy()
const { excludedItems } = await read.json()
expect(excludedItems).toEqual([
expect.objectContaining({ platform: 'instagram', itemId: 'e2e-ig-muro-1' }),
])
} finally {
await request
.post(`${baseURL}/api/globals/social-feed-settings`, {
headers,
data: {
enabled: true,
youtubeEnabled: true,
youtubeApiKey: '',
youtubeChannelId: '',
youtubeMaxItems: 3,
instagramEnabled: true,
instagramAccessToken: '',
instagramUserId: '',
instagramMaxItems: 3,
excludedItems: [],
youtubeFeedSnapshot: null,
instagramFeedSnapshot: null,
},
})
.catch(() => undefined)
}
})
})

View file

@ -1,7 +1,7 @@
import type { APIRequestContext, Page } from '@playwright/test'
import { seedTestUser, testUser } from '../helpers/seedUser'
import { youtubeStubUrlFor } from '../helpers/youtubeStub'
import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub'
import { expect, test } from './fixtures/e2eTest'
const swipeLeft = async (
@ -611,16 +611,23 @@ test.describe('Campaign home content section', () => {
}
}
// ---- S2 — YouTube feed: stub + settings helpers ----
// ---- S2/S3 — YouTube + Instagram feed: stub + settings helpers ----
// Same derivation as playwright.config.ts: the stub listens on dev port + 1000.
// Same derivation as playwright.config.ts: the stubs listen on dev port +
// 1000 (YouTube) and + 2000 (Instagram).
const youtubeStubUrl = youtubeStubUrlFor(baseURL)
const instagramStubUrl = instagramStubUrlFor(baseURL)
const setYouTubeStubState = async (request: APIRequestContext, state: 'ok' | 'fail') => {
const response = await request.post(`${youtubeStubUrl}/__stub/state`, { data: { state } })
expect(response.ok()).toBeTruthy()
}
const setInstagramStubState = async (request: APIRequestContext, state: 'ok' | 'fail') => {
const response = await request.post(`${instagramStubUrl}/__stub/state`, { data: { state } })
expect(response.ok()).toBeTruthy()
}
const updateSocialFeedSettings = async (
request: APIRequestContext,
headers: Record<string, string>,
@ -633,7 +640,7 @@ test.describe('Campaign home content section', () => {
expect(response.ok()).toBeTruthy()
}
// Full unconfigured state: the feed returns null and the S1 behavior is intact.
// Full unconfigured state: both feeds return null and the S1 behavior is intact.
const resetSocialFeedSettings = async (
request: APIRequestContext,
headers: Record<string, string>,
@ -644,8 +651,13 @@ test.describe('Campaign home content section', () => {
youtubeApiKey: '',
youtubeChannelId: '',
youtubeMaxItems: 3,
instagramEnabled: true,
instagramAccessToken: '',
instagramUserId: '',
instagramMaxItems: 3,
excludedItems: [],
youtubeFeedSnapshot: null,
instagramFeedSnapshot: null,
})
}
@ -1072,6 +1084,224 @@ test.describe('Campaign home content section', () => {
}
})
test('renders the Instagram feed with exclusions and opens posts on the platform', async ({
page,
request,
}) => {
await seedTestUser()
const headers = await adminHeaders(request)
await setYouTubeStubState(request, 'ok')
await setInstagramStubState(request, 'ok')
const mixTag = await createTag(
request,
headers,
`E2e Mix IG ${runSuffix}`,
`e2e-mix-ig-${runSuffix}`,
)
await createPost(request, headers, {
title: 'E2e Artigo no mix IG',
slug: `e2e-artigo-mix-ig-${runSuffix}`,
type: 'artigo',
category: mixTag.id,
publishedDate: new Date(Date.now() - 60 * 60_000).toISOString(),
})
// The newest stub post is a grid mosaic marked as excluded: the board must
// skip it and promote the next eligible one to the featured slot.
await updateSocialFeedSettings(request, headers, {
enabled: true,
youtubeEnabled: true,
youtubeApiKey: '',
youtubeChannelId: '',
youtubeMaxItems: 3,
instagramEnabled: true,
instagramAccessToken: 'e2e-ig-token',
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
excludedItems: [{ platform: 'instagram', itemId: 'e2e-ig-grade-excluido-5' }],
youtubeFeedSnapshot: null,
instagramFeedSnapshot: null,
})
try {
await waitForHomeHTML(
request,
['E2e Post do muro', 'E2e Artigo no mix IG'],
['E2e Post de grade', 'YouTube →'],
)
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto('/')
const section = page.locator('[data-home-section="contents"]')
await expect(section).toBeVisible()
const featured = section.getByRole('link', { name: /E2e Post do muro/ })
await expect(featured).toBeVisible()
await expect(featured).toHaveAttribute('href', 'https://www.instagram.com/p/e2e-ig-muro-1/')
await expect(featured).toHaveAttribute('target', '_blank')
await expect(featured).toHaveAttribute('rel', 'noopener noreferrer')
await expect(section.getByText('Instagram', { exact: true }).first()).toBeVisible()
await expect(section.getByText('há 20 minutos').first()).toBeVisible()
await expect(section.getByText('E2e Post de grade')).toHaveCount(0)
// The captionless grid post shows the fallback title (it enters the
// board automatically and the assessoria excludes it item by item).
await expect(section.getByText('Publicação no Instagram').first()).toBeVisible()
// Instagram cards render the 1:1 cover of the approved draft.
await expect(featured.locator('.aspect-square').first()).toBeVisible()
await expect(
section.getByRole('link', { name: /E2e Artigo no mix IG/ }).first(),
).toBeVisible()
await expect(section.getByText('E2e Reel da caravana').first()).toBeVisible()
const profileLink = section.getByRole('link', { name: /Seguir no Instagram/ })
await expect(profileLink).toHaveAttribute('href', 'https://www.instagram.com/depjorgesolla')
await expect(profileLink).toHaveAttribute('target', '_blank')
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
const carousel = page.getByRole('region', { name: 'Conteúdos recentes' })
await expect(carousel).toBeVisible()
await expect(carousel.getByText('1 de 4 · deslize para ver os próximos')).toBeVisible()
await expect(carousel.getByText('E2e Post de grade')).toHaveCount(0)
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
} finally {
await setInstagramStubState(request, 'ok')
await cleanupS2Fixtures(request, headers)
}
})
test('hides Instagram cards and keeps articles when the API fails without a snapshot', async ({
page,
request,
}) => {
await seedTestUser()
const headers = await adminHeaders(request)
await setInstagramStubState(request, 'fail')
const fallbackTag = await createTag(
request,
headers,
`E2e Fallback IG ${runSuffix}`,
`e2e-fallback-ig-${runSuffix}`,
)
await createPost(request, headers, {
title: 'E2e Artigo fallback IG',
slug: `e2e-artigo-fallback-ig-${runSuffix}`,
type: 'noticia',
category: fallbackTag.id,
publishedDate: new Date(Date.now() - 2 * 60 * 60_000).toISOString(),
})
await updateSocialFeedSettings(request, headers, {
enabled: true,
youtubeEnabled: true,
youtubeApiKey: '',
youtubeChannelId: '',
youtubeMaxItems: 3,
instagramEnabled: true,
instagramAccessToken: 'e2e-ig-token',
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
excludedItems: [],
youtubeFeedSnapshot: null,
instagramFeedSnapshot: null,
})
try {
// Without a snapshot there is no username to build the profile link
// from, so the IG cards AND the "Seguir no Instagram →" link stay
// silently absent and the article carries the section (page never
// breaks, no error shown).
await waitForHomeHTML(
request,
['E2e Artigo fallback IG'],
['E2e Post do muro', 'Seguir no Instagram'],
)
await page.goto('/')
const section = page.locator('[data-home-section="contents"]')
await expect(section).toBeVisible()
await expect(section.getByRole('link', { name: /E2e Artigo fallback IG/ })).toBeVisible()
await expect(section.getByText('E2e Post do muro')).toHaveCount(0)
await expect(section.getByRole('link', { name: /Seguir no Instagram/ })).toHaveCount(0)
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
} finally {
await setInstagramStubState(request, 'ok')
await cleanupS2Fixtures(request, headers)
}
})
test('keeps the last Instagram snapshot while the API is down', async ({ page, request }) => {
await seedTestUser()
const headers = await adminHeaders(request)
await setInstagramStubState(request, 'ok')
const baseSettings = {
enabled: true,
youtubeEnabled: true,
youtubeApiKey: '',
youtubeChannelId: '',
youtubeMaxItems: 3,
instagramEnabled: true,
instagramAccessToken: 'e2e-ig-token',
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
excludedItems: [{ platform: 'instagram', itemId: 'e2e-ig-grade-excluido-5' }],
youtubeFeedSnapshot: null,
instagramFeedSnapshot: null,
}
await updateSocialFeedSettings(request, headers, baseSettings)
try {
// Live fetch succeeds and persists the raw snapshot (with the username).
await waitForHomeHTML(request, ['E2e Post do muro'], ['E2e Post de grade'])
await page.goto('/')
await expect(page.getByRole('link', { name: /E2e Post do muro/ }).first()).toBeVisible()
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
// API down + a settings change: the re-execution must serve the snapshot
// re-filtered by the CURRENT exclusions/maxItems (raw snapshot, applied
// on read) — maxItems 2 proves the re-run read the new settings, and the
// profile link survives because the username came from the snapshot. The
// snapshot field is OMITTED on purpose: an update merges over the
// existing doc (Payload update semantics), so the persisted snapshot
// survives the settings edit — exactly the production admin path.
await setInstagramStubState(request, 'fail')
const { instagramFeedSnapshot: _ignored, ...settingsWithoutSnapshot } = baseSettings
await updateSocialFeedSettings(request, headers, {
...settingsWithoutSnapshot,
instagramMaxItems: 2,
})
await waitForHomeHTML(
request,
['E2e Post do muro', 'E2e Reel da caravana', 'Seguir no Instagram'],
['Publicação no Instagram'],
)
await page.goto('/')
await expect(page.getByRole('link', { name: /E2e Post do muro/ }).first()).toBeVisible()
await expect(page.getByText('E2e Reel da caravana').first()).toBeVisible()
await expect(page.getByText('Publicação no Instagram')).toHaveCount(0)
await expect(page.getByRole('link', { name: /Seguir no Instagram/ })).toBeVisible()
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
// API back: the live feed returns with the original cap.
await setInstagramStubState(request, 'ok')
await updateSocialFeedSettings(request, headers, baseSettings)
await waitForHomeHTML(request, ['Publicação no Instagram'], ['E2e Post de grade'])
await page.goto('/')
await expect(page.getByText('Publicação no Instagram').first()).toBeVisible()
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
} finally {
await setInstagramStubState(request, 'ok')
await cleanupS2Fixtures(request, headers)
}
})
test('the kill switch pauses the external feed without touching articles', async ({
page,
request,
@ -1079,6 +1309,7 @@ test.describe('Campaign home content section', () => {
await seedTestUser()
const headers = await adminHeaders(request)
await setYouTubeStubState(request, 'ok')
await setInstagramStubState(request, 'ok')
const killTag = await createTag(
request,
@ -1099,17 +1330,23 @@ test.describe('Campaign home content section', () => {
youtubeApiKey: 'e2e-youtube-api-key',
youtubeChannelId: 'UCe2eTestChannel',
youtubeMaxItems: 3,
instagramEnabled: true,
instagramAccessToken: 'e2e-ig-token',
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
excludedItems: [],
youtubeFeedSnapshot: null,
instagramFeedSnapshot: null,
})
try {
// `enabled: false` pauses the whole external board: no cards AND no
// channel link, while the article section lives on.
// platform links (YouTube and Instagram alike), while the article
// section lives on.
await waitForHomeHTML(
request,
['E2e Artigo kill switch'],
['E2e Vídeo em destaque', 'YouTube →'],
['E2e Vídeo em destaque', 'YouTube →', 'E2e Post do muro', 'Seguir no Instagram'],
)
await page.goto('/')
const section = page.locator('[data-home-section="contents"]')
@ -1117,11 +1354,14 @@ test.describe('Campaign home content section', () => {
await expect(section.getByRole('link', { name: /E2e Artigo kill switch/ })).toBeVisible()
await expect(section.getByText('E2e Vídeo em destaque')).toHaveCount(0)
await expect(section.getByRole('link', { name: /YouTube →/ })).toHaveCount(0)
await expect(section.getByText('E2e Post do muro')).toHaveCount(0)
await expect(section.getByRole('link', { name: /Seguir no Instagram/ })).toHaveCount(0)
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
} finally {
await setYouTubeStubState(request, 'ok')
await setInstagramStubState(request, 'ok')
await cleanupS2Fixtures(request, headers)
}
})

View file

@ -0,0 +1,144 @@
/**
* Deterministic Instagram Graph API stub for e2e — the campaign home content
* board (S3) fetches the feed server-side, so the Next dev/prod server points
* `INSTAGRAM_API_BASE_URL` here (see playwright.config.ts) and this process
* answers `/media`, `/user` and `/refresh_access_token` with a fixed fixture.
* `POST /__stub/state` with `{ "state": "ok" | "fail" }` makes the API fail
* (HTTP 500) so specs can exercise the fail-closed/snapshot paths without any
* network. Thumbnails are served locally from `/thumbs/<id>.jpg` — the
* sandbox never reaches `scontent.cdninstagram.com`, and next/image would 404
* (a console error the e2e guard treats as a failure).
*/
import { createServer } from 'node:http'
const PORT = Number(process.env.INSTAGRAM_STUB_PORT ?? 5000)
const USER_ID = '17841400000000000'
const USERNAME = 'depjorgesolla'
const ORIGIN = `http://localhost:${PORT}`
// 1x1 transparent PNG — enough for the optimizer to proxy successfully.
const THUMBNAIL_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
)
const minutesAgo = (minutes) => new Date(Date.now() - minutes * 60_000).toISOString()
const POSTS = [
{
id: 'e2e-ig-muro-1',
caption: 'E2e Post do muro',
media_type: 'IMAGE',
minutesAgo: 20,
},
{
id: 'e2e-ig-reel-2',
caption: 'E2e Reel da caravana',
media_type: 'REEL',
minutesAgo: 90,
},
{
id: 'e2e-ig-sem-legenda-3',
caption: null,
media_type: 'IMAGE',
minutesAgo: 26 * 60,
},
{
id: 'e2e-ig-carrossel-4',
caption: 'E2e Carrossel de fotos',
media_type: 'CAROUSEL_ALBUM',
minutesAgo: 30 * 60,
},
{
id: 'e2e-ig-grade-excluido-5',
caption: 'E2e Post de grade',
media_type: 'IMAGE',
minutesAgo: 5,
},
]
const mediaItem = (post) => {
const base = `${ORIGIN}/thumbs/${post.id}.jpg`
const item = {
id: post.id,
caption: post.caption,
media_type: post.media_type,
permalink: `https://www.instagram.com/p/${post.id}/`,
timestamp: minutesAgo(post.minutesAgo),
media_url: base,
thumbnail_url: base,
}
if (post.media_type === 'CAROUSEL_ALBUM') {
item.children = { data: [{ media_url: base, thumbnail_url: base }] }
}
return item
}
const mediaResponse = () => ({ data: POSTS.map(mediaItem) })
let state = 'ok'
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
const server = createServer((req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`)
if (url.pathname === '/__stub/health') {
json(res, 200, { ok: true })
return
}
if (url.pathname === '/__stub/state' && req.method === 'POST') {
let body = ''
req.on('data', (chunk) => {
body += chunk
})
req.on('end', () => {
try {
const parsed = JSON.parse(body)
if (parsed.state === 'ok' || parsed.state === 'fail') state = parsed.state
} catch {
// keep the current state
}
json(res, 200, { state })
})
return
}
if (state === 'fail') {
res.writeHead(500)
res.end('stub fail')
return
}
if (url.pathname.endsWith('/media')) {
json(res, 200, mediaResponse())
return
}
if (url.pathname === '/refresh_access_token') {
json(res, 200, { access_token: 'e2e-refreshed-token' })
return
}
if (url.pathname.endsWith(`/${USER_ID}`) || url.pathname === '/user') {
json(res, 200, { id: USER_ID, username: USERNAME })
return
}
if (url.pathname.startsWith('/thumbs/')) {
res.writeHead(200, { 'content-type': 'image/png' })
res.end(THUMBNAIL_PNG)
return
}
res.writeHead(404)
res.end('not found')
})
server.listen(PORT, () => {
console.log(`instagram e2e stub listening on :${PORT}`)
})

View file

@ -0,0 +1,17 @@
/**
* Port/URL of the social-feed stubs the e2e board (S2 YouTube, S3 Instagram)
* fetches. The single spelling of the derivation: dev servers live in
* 3100..4099 (worktree slots, capped at 999), so the offsets never collide
* with another worktree's dev server or with each other; CI stays on 4000
* (YouTube) and 5000 (Instagram). Shared by playwright.config.ts (webServer +
* env) and the frontend spec (state switches) so the two cannot drift apart.
*/
export const youtubeStubUrlFor = (baseURL: string): string => {
const port = new URL(baseURL).port || (baseURL.startsWith('https:') ? '443' : '80')
return `http://localhost:${Number(port) + 1000}`
}
export const instagramStubUrlFor = (baseURL: string): string => {
const port = new URL(baseURL).port || (baseURL.startsWith('https:') ? '443' : '80')
return `http://localhost:${Number(port) + 2000}`
}

View file

@ -1,11 +0,0 @@
/**
* Port/URL of the YouTube Data API stub the e2e board (S2) fetches. The single
* spelling of the derivation: dev servers live in 3100..4099 (worktree slots,
* capped at 999), so +1000 never collides with another worktree's dev server;
* CI stays on 4000. Shared by playwright.config.ts (webServer + env) and the
* frontend spec (state switches) so the two cannot drift apart.
*/
export const youtubeStubUrlFor = (baseURL: string): string => {
const port = new URL(baseURL).port || (baseURL.startsWith('https:') ? '443' : '80')
return `http://localhost:${Number(port) + 1000}`
}

View file

@ -496,11 +496,11 @@ describe('src/utilities top-level is pinned', () => {
'voteEstimateScenarioFields.ts',
'votePledgeData.ts',
'votePledgeViews.ts',
// S2 — YouTube Data API v3 feed of the campaign home content board
// (fetch + cache + snapshot + formatters). A sibling of `posts.ts`, not a
// domain subfolder: only one external source so far — the Instagram feed
// (S3) would earn a `socialFeed/` subfolder instead of a third top-level.
'youtubeFeed.ts',
// The campaign home content board's external feeds (YouTube Data API v3
// and Instagram Graph API) live in the `socialFeed/` subfolder — the S2
// pin predicted it: one external source stayed top-level, the second
// earned the domain folder. Fetch + cache + snapshot + formatters, per
// platform, siblings of `posts.ts`.
])
it('fails the build on an unregistered top-level module', () => {

View file

@ -0,0 +1,337 @@
import { describe, expect, it } from 'vitest'
import type { InstagramPost } from '@/utilities/socialFeed/instagramFeed'
import {
INSTAGRAM_MAX_RESULTS_CAP,
eligibleInstagramPosts,
loadInstagramFeed,
parseInstagramMediaResponse,
pickInstagramThumbnail,
} from '@/utilities/socialFeed/instagramFeed'
const MEDIA_ITEM = (
id: string,
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
id,
caption: `Legenda ${id}`,
media_type: 'IMAGE',
media_url: `https://scontent.cdninstagram.com/${id}.jpg`,
permalink: `https://www.instagram.com/p/${id}/`,
timestamp: '2026-08-18T10:00:00+00:00',
...overrides,
})
describe('pickInstagramThumbnail', () => {
it('uses the media URL for images', () => {
expect(pickInstagramThumbnail(MEDIA_ITEM('img'))).toBe(
'https://scontent.cdninstagram.com/img.jpg',
)
})
it('uses the thumbnail for videos and reels, never the mp4', () => {
expect(
pickInstagramThumbnail(
MEDIA_ITEM('vid', {
media_type: 'VIDEO',
media_url: 'https://scontent.cdninstagram.com/vid.mp4',
thumbnail_url: 'https://scontent.cdninstagram.com/vid.jpg',
}),
),
).toBe('https://scontent.cdninstagram.com/vid.jpg')
expect(
pickInstagramThumbnail(
MEDIA_ITEM('reel', {
media_type: 'REEL',
thumbnail_url: 'https://scontent.cdninstagram.com/reel.jpg',
}),
),
).toBe('https://scontent.cdninstagram.com/reel.jpg')
})
it('resolves carousels from the first child that carries a URL', () => {
expect(
pickInstagramThumbnail(
MEDIA_ITEM('car', {
media_type: 'CAROUSEL_ALBUM',
children: {
data: [
{ media_url: 'https://scontent.cdninstagram.com/first.jpg' },
{ media_url: 'https://scontent.cdninstagram.com/second.jpg' },
],
},
}),
),
).toBe('https://scontent.cdninstagram.com/first.jpg')
expect(
pickInstagramThumbnail(
MEDIA_ITEM('car2', {
media_type: 'CAROUSEL_ALBUM',
children: {
data: [
{ thumbnail_url: 'https://scontent.cdninstagram.com/child-video.jpg' },
{ media_url: 'https://scontent.cdninstagram.com/child-image.jpg' },
],
},
}),
),
).toBe('https://scontent.cdninstagram.com/child-video.jpg')
})
it('reads missing or malformed thumbnails as undefined', () => {
expect(
pickInstagramThumbnail(
MEDIA_ITEM('no-thumb', { media_type: 'REEL', thumbnail_url: undefined }),
),
).toBeUndefined()
expect(
pickInstagramThumbnail(
MEDIA_ITEM('empty-children', { media_type: 'CAROUSEL_ALBUM', children: { data: [] } }),
),
).toBeUndefined()
expect(
pickInstagramThumbnail(
MEDIA_ITEM('bad-children', {
media_type: 'CAROUSEL_ALBUM',
children: { data: ['nope'] },
}),
),
).toBeUndefined()
expect(
pickInstagramThumbnail(
MEDIA_ITEM('no-type', { media_type: undefined, media_url: undefined }),
),
).toBeUndefined()
})
})
describe('parseInstagramMediaResponse', () => {
it('parses a valid media response, keeping null captions', () => {
const posts = parseInstagramMediaResponse({
data: [
MEDIA_ITEM('post1'),
MEDIA_ITEM('post2', { caption: null, media_type: 'REEL', thumbnail_url: 'thumb.jpg' }),
MEDIA_ITEM('post3', {
media_type: 'CAROUSEL_ALBUM',
children: { data: [{ media_url: 'child.jpg' }] },
}),
],
})
expect(posts).toHaveLength(3)
expect(posts[0]).toEqual({
id: 'post1',
caption: 'Legenda post1',
mediaType: 'IMAGE',
permalink: 'https://www.instagram.com/p/post1/',
thumbnailUrl: 'https://scontent.cdninstagram.com/post1.jpg',
timestamp: '2026-08-18T10:00:00+00:00',
})
expect(posts[1].caption).toBeNull()
expect(posts[1].thumbnailUrl).toBe('thumb.jpg')
expect(posts[2].thumbnailUrl).toBe('child.jpg')
})
it('drops items without id, permalink or timestamp but throws on protocol violations', () => {
expect(
parseInstagramMediaResponse({
data: [
MEDIA_ITEM('ok'),
{ ...MEDIA_ITEM('no-permalink'), permalink: undefined },
{ ...MEDIA_ITEM('no-timestamp'), timestamp: undefined },
{ id: 'no-caption', permalink: 'https://instagram.com/p/x/', timestamp: 't' },
'malformed',
],
}),
).toEqual([
expect.objectContaining({ id: 'ok' }),
expect.objectContaining({ id: 'no-caption', caption: null }),
])
expect(() => parseInstagramMediaResponse({})).toThrow()
expect(() => parseInstagramMediaResponse(null)).toThrow()
expect(() => parseInstagramMediaResponse({ data: 'nope' })).toThrow()
})
})
describe('eligibleInstagramPosts', () => {
const posts: InstagramPost[] = [
{
id: 'a',
caption: 'A',
mediaType: 'IMAGE',
permalink: 'https://instagram.com/p/a/',
timestamp: 't1',
},
{
id: 'b',
caption: 'B',
mediaType: 'IMAGE',
permalink: 'https://instagram.com/p/b/',
timestamp: 't2',
},
{
id: 'c',
caption: 'C',
mediaType: 'IMAGE',
permalink: 'https://instagram.com/p/c/',
timestamp: 't3',
},
{
id: 'd',
caption: 'D',
mediaType: 'IMAGE',
permalink: 'https://instagram.com/p/d/',
timestamp: 't4',
},
]
it('drops excluded ids and caps at maxItems, keeping API order', () => {
expect(eligibleInstagramPosts(posts, ['b'], 2)).toEqual([posts[0], posts[2]])
expect(eligibleInstagramPosts(posts, [], 3)).toEqual(posts.slice(0, 3))
expect(eligibleInstagramPosts(posts, ['a', 'b', 'c', 'd'], 5)).toEqual([])
})
})
describe('loadInstagramFeed', () => {
const fakeResponse = (body: unknown, ok = true): Response =>
({ ok, json: async () => body }) as unknown as Response
const calls: string[] = []
const fetchImpl = async (input: string): Promise<Response> => {
calls.push(input)
if (input.includes('/media')) {
return fakeResponse({
data: [
MEDIA_ITEM('post1', { caption: 'Post 1' }),
MEDIA_ITEM('post2', { caption: 'Post 2' }),
],
})
}
if (input.includes('/refresh_access_token')) {
return fakeResponse({ access_token: 'refreshed-token' })
}
return fakeResponse({ username: 'depjorgesolla' })
}
const args = {
accessToken: 'test-token',
userId: '17841400000000000',
maxResults: 3,
fetchImpl,
baseUrl: 'http://localhost:5000',
}
it('fetches user then media and resolves the thumbnail per type', async () => {
calls.length = 0
const result = await loadInstagramFeed(args)
expect(result.username).toBe('depjorgesolla')
expect(result.refreshedAccessToken).toBeUndefined()
expect(result.posts).toEqual([
expect.objectContaining({ id: 'post1', caption: 'Post 1' }),
expect.objectContaining({ id: 'post2' }),
])
const userCall = calls[0]
expect(userCall).toContain(`/17841400000000000?`)
expect(userCall).toContain('fields=username')
expect(userCall).toContain(`access_token=test-token`)
expect(calls[1]).toContain('/media?')
expect(calls[1]).toContain('fields=id%2Ccaption%2Cmedia_type%2Cmedia_url')
expect(calls[1]).toContain('children%7Bmedia_url%2Cthumbnail_url%7D')
expect(calls[1]).toContain('limit=3')
})
it('clamps maxResults to the API cap and the minimum of 1', async () => {
calls.length = 0
await loadInstagramFeed({ ...args, maxResults: 100 })
expect(calls[1]).toContain(`limit=${INSTAGRAM_MAX_RESULTS_CAP}`)
calls.length = 0
await loadInstagramFeed({ ...args, maxResults: 0 })
expect(calls[1]).toContain('limit=1')
})
it('refreshes the token once and retries when the media call fails', async () => {
calls.length = 0
let mediaFailures = 1
const result = await loadInstagramFeed({
...args,
fetchImpl: async (input) => {
calls.push(input)
if (input.includes('/media')) {
if (mediaFailures > 0) {
mediaFailures -= 1
return fakeResponse({ error: { code: 190 } }, false)
}
return fakeResponse({ data: [MEDIA_ITEM('post-retry')] })
}
if (input.includes('/refresh_access_token')) {
return fakeResponse({ access_token: 'refreshed-token' })
}
return fakeResponse({ username: 'depjorgesolla' })
},
})
expect(result.refreshedAccessToken).toBe('refreshed-token')
expect(result.posts).toEqual([expect.objectContaining({ id: 'post-retry' })])
const refreshCall = calls.find((call) => call.includes('/refresh_access_token'))
expect(calls.filter((call) => call.includes('/refresh_access_token'))).toHaveLength(1)
expect(calls.filter((call) => call.includes('/media'))).toHaveLength(2)
expect(refreshCall).toContain('grant_type=ig_refresh_token')
expect(refreshCall).toContain('access_token=test-token')
})
it('throws when the refresh fails or returns no token (fail-closed to the snapshot)', async () => {
await expect(
loadInstagramFeed({
...args,
fetchImpl: async () => fakeResponse({}, false),
}),
).rejects.toThrow()
await expect(
loadInstagramFeed({
...args,
fetchImpl: async (input) =>
input.includes('/media') ? fakeResponse({}, false) : fakeResponse({}, true),
}),
).rejects.toThrow(/refresh/)
await expect(
loadInstagramFeed({
...args,
fetchImpl: async (input) =>
input.includes('/media') ? fakeResponse({}, false) : fakeResponse({ nope: true }),
}),
).rejects.toThrow(/sem novo token/)
})
it('throws on protocol violations and a failed retry', async () => {
await expect(
loadInstagramFeed({
...args,
fetchImpl: async () => fakeResponse({ data: 'nope' }),
}),
).rejects.toThrow()
await expect(
loadInstagramFeed({
...args,
fetchImpl: async (input) =>
input.includes('/media') ? fakeResponse({}, false) : fakeResponse({}, false),
}),
).rejects.toThrow()
})
it('reads a missing or malformed username as null', async () => {
const result = await loadInstagramFeed({
...args,
fetchImpl: async (input) =>
input.includes('/media') ? fakeResponse({ data: [MEDIA_ITEM('p')] }) : fakeResponse({}),
})
expect(result.username).toBeNull()
})
})

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { YouTubeVideo } from '@/utilities/youtubeFeed'
import type { YouTubeVideo } from '@/utilities/socialFeed/youtubeFeed'
import {
YOUTUBE_MAX_RESULTS_CAP,
eligibleYouTubeVideos,
@ -9,7 +9,7 @@ import {
parseYouTubeSearchResponse,
parseYouTubeVideosResponse,
pickThumbnailUrl,
} from '@/utilities/youtubeFeed'
} from '@/utilities/socialFeed/youtubeFeed'
const SEARCH_ITEM = (videoId: string, title: string, publishedAt: string, extra?: object) => ({
id: { videoId },