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:
parent
a6c8ece2f5
commit
a638a8acae
25 changed files with 15370 additions and 49 deletions
|
|
@ -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.
|
- **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`.
|
- `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.
|
- **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.
|
- This POST is the required last step of the post-seed / direct-DB-change runbook.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
**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).
|
**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.
|
**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.
|
||||||
|
|
|
||||||
1
docs/changelog/2026-08-18-s3.md
Normal file
1
docs/changelog/2026-08-18-s3.md
Normal 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).
|
||||||
119
docs/plans/secao-conteudos-home-instagram-impl.md
Normal file
119
docs/plans/secao-conteudos-home-instagram-impl.md
Normal 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)
|
||||||
|
|
@ -10,9 +10,13 @@ const nextConfig = {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
new URL(`${allowedImageHost}/**`),
|
new URL(`${allowedImageHost}/**`),
|
||||||
// YouTube video thumbnails (campaign home content board, S2) come from
|
// 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/**'),
|
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.
|
// fixture thumbnails locally so specs never touch the real network.
|
||||||
{ protocol: 'http', hostname: 'localhost', pathname: '/thumbs/**' },
|
{ protocol: 'http', hostname: 'localhost', pathname: '/thumbs/**' },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME,
|
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME,
|
||||||
GOOGLE_CALENDAR_TEST_KEY,
|
GOOGLE_CALENDAR_TEST_KEY,
|
||||||
} from './tests/helpers/googleCalendarTestKey'
|
} 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
|
* 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 baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
|
||||||
const webServerPort = new URL(baseURL).port || (baseURL.startsWith('https:') ? '443' : '80')
|
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.
|
* Ports of the social-feed stubs the content board fetches in e2e (S2
|
||||||
* dev ports live in 3100..4099 (worktree slots, capped at 999), so +1000
|
* YouTube, S3 Instagram). dev ports live in 3100..4099 (worktree slots,
|
||||||
* never collides with another worktree's dev server; CI stays on 4000. The
|
* capped at 999), so +1000 (YouTube) and +2000 (Instagram) never collide
|
||||||
* derivation lives once in `tests/e2e/helpers/youtubeStub.ts` (the spec uses
|
* with another worktree's dev server nor with each other; CI stays on 4000
|
||||||
* it to flip the stub state).
|
* 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 youtubeStubUrl = youtubeStubUrlFor(baseURL)
|
||||||
const youtubeStubPort = new URL(youtubeStubUrl).port
|
const youtubeStubPort = new URL(youtubeStubUrl).port
|
||||||
|
const instagramStubUrl = instagramStubUrlFor(baseURL)
|
||||||
|
const instagramStubPort = new URL(instagramStubUrl).port
|
||||||
const workersOverride = process.env.PLAYWRIGHT_WORKERS
|
const workersOverride = process.env.PLAYWRIGHT_WORKERS
|
||||||
const workers = workersOverride === undefined ? 2 : Number(workersOverride)
|
const workers = workersOverride === undefined ? 2 : Number(workersOverride)
|
||||||
if (!Number.isInteger(workers) || workers < 2) {
|
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),
|
// Lets e2e specs bust the `posts` tag after direct REST deletes (cleanup),
|
||||||
// mirroring the documented post-seed runbook against the deployed site.
|
// mirroring the documented post-seed runbook against the deployed site.
|
||||||
REVALIDATE_SECRET: process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret',
|
REVALIDATE_SECRET: process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret',
|
||||||
// The content board (S2) fetches the YouTube Data API through the local
|
// The content board (S2/S3) fetches the YouTube Data API and the
|
||||||
// stub below instead of the real network (deterministic fixtures).
|
// Instagram Graph API through the local stubs below instead of the
|
||||||
|
// real network (deterministic fixtures).
|
||||||
YOUTUBE_API_BASE_URL: youtubeStubUrl,
|
YOUTUBE_API_BASE_URL: youtubeStubUrl,
|
||||||
|
INSTAGRAM_API_BASE_URL: instagramStubUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -203,5 +208,15 @@ export default defineConfig({
|
||||||
YOUTUBE_STUB_PORT: String(youtubeStubPort),
|
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),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import { ExportPreview as ExportPreview_cdf7e044479f899a31f804427d568b36 } from
|
||||||
import { ExportSaveButton as ExportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
|
import { ExportSaveButton as ExportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
|
||||||
import { ImportPreview as ImportPreview_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 { 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 { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
|
||||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/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#ExportSaveButton": ExportSaveButton_cdf7e044479f899a31f804427d568b36,
|
||||||
"@payloadcms/plugin-import-export/rsc#ImportPreview": ImportPreview_cdf7e044479f899a31f804427d568b36,
|
"@payloadcms/plugin-import-export/rsc#ImportPreview": ImportPreview_cdf7e044479f899a31f804427d568b36,
|
||||||
"@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36,
|
"@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36,
|
||||||
|
"./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0,
|
||||||
"@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36,
|
"@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36,
|
||||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export type CampaignContentCardData = {
|
||||||
coverUrl?: string
|
coverUrl?: string
|
||||||
coverAlt?: string
|
coverAlt?: string
|
||||||
subtitle?: 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 target (e.g. a YouTube video): opens in a new tab with noopener. */
|
||||||
external?: boolean
|
external?: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +35,11 @@ const cardClassName = (featured: boolean) =>
|
||||||
export const CampaignContentCard = ({ card, featured = false }: CampaignContentCardProps) => {
|
export const CampaignContentCard = ({ card, featured = false }: CampaignContentCardProps) => {
|
||||||
const inner = (
|
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 ? (
|
{card.coverUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={card.coverUrl}
|
src={card.coverUrl}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,12 @@ import {
|
||||||
getVisiblePosts,
|
getVisiblePosts,
|
||||||
POST_TYPE_BADGE_LABELS,
|
POST_TYPE_BADGE_LABELS,
|
||||||
} from '@/utilities/posts'
|
} 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'
|
import Link from 'next/link'
|
||||||
|
|
||||||
const CONTENT_SECTION_LIMIT = 5
|
const CONTENT_SECTION_LIMIT = 5
|
||||||
|
|
@ -69,23 +74,44 @@ const toVideoCardData = (video: YouTubeVideo): DatedCard => ({
|
||||||
date: dateTimeOf(video.publishedAt),
|
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
|
* 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
|
* posts (S1), the eligible YouTube feed (S2) and the eligible Instagram feed
|
||||||
* on desktop and a one-per-screen carousel on mobile. Hides entirely when
|
* (S3), newest first, as a 1+4 bento on desktop and a one-per-screen carousel
|
||||||
* nothing is visible (`isPostVisible` fail-closed — hidden electoral tags
|
* on mobile. Hides entirely when nothing is visible (`isPostVisible`
|
||||||
* never leak) and never breaks when the feed API is down (snapshot or no
|
* fail-closed — hidden electoral tags never leak) and never breaks when a
|
||||||
* cards). With the feed unconfigured it degrades to the S1 articles-only
|
* feed API is down (snapshot or no cards). With the feeds unconfigured it
|
||||||
* behavior.
|
* degrades to the S1 articles-only behavior.
|
||||||
*/
|
*/
|
||||||
export const CampaignContentSection = async () => {
|
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
|
const articleCards = visiblePosts
|
||||||
.map(toArticleCardData)
|
.map(toArticleCardData)
|
||||||
.filter((entry): entry is DatedCard => entry !== null)
|
.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)
|
.sort(byRecencyDesc)
|
||||||
.slice(0, CONTENT_SECTION_LIMIT)
|
.slice(0, CONTENT_SECTION_LIMIT)
|
||||||
.map(({ card }) => card)
|
.map(({ card }) => card)
|
||||||
|
|
@ -120,9 +146,19 @@ export const CampaignContentSection = async () => {
|
||||||
<Link href="/artigos" className={sectionHeaderLinkClassName}>
|
<Link href="/artigos" className={sectionHeaderLinkClassName}>
|
||||||
Ver artigos →
|
Ver artigos →
|
||||||
</Link>
|
</Link>
|
||||||
{feed ? (
|
{instagramFeed?.username ? (
|
||||||
<a
|
<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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={sectionHeaderLinkClassName}
|
className={sectionHeaderLinkClassName}
|
||||||
|
|
|
||||||
139
src/components/admin/InstagramPostExclusionPicker.tsx
Normal file
139
src/components/admin/InstagramPostExclusionPicker.tsx
Normal 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 "Não exibir" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -79,6 +79,53 @@ export const SocialFeedSettings: GlobalConfig = {
|
||||||
description: 'Quantos vídeos entram no bento (1 grande + os próximos).',
|
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',
|
name: 'excludedItems',
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|
@ -131,5 +178,12 @@ export const SocialFeedSettings: GlobalConfig = {
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'instagramFeedSnapshot',
|
||||||
|
type: 'json',
|
||||||
|
admin: {
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13749
src/migrations/20260818_192948_add_instagram_social_feed_fields.json
Normal file
13749
src/migrations/20260818_192948_add_instagram_social_feed_fields.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||||
|
}
|
||||||
|
|
@ -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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -340,6 +341,11 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260818_111748_add_social_feed_settings.up,
|
up: migration_20260818_111748_add_social_feed_settings.up,
|
||||||
down: migration_20260818_111748_add_social_feed_settings.down,
|
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'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2395,6 +2395,22 @@ export interface SocialFeedSetting {
|
||||||
* Quantos vídeos entram no bento (1 grande + os próximos).
|
* Quantos vídeos entram no bento (1 grande + os próximos).
|
||||||
*/
|
*/
|
||||||
youtubeMaxItems?: number | null;
|
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.
|
* 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
|
| number
|
||||||
| boolean
|
| boolean
|
||||||
| null;
|
| null;
|
||||||
|
instagramFeedSnapshot?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -2513,6 +2538,10 @@ export interface SocialFeedSettingsSelect<T extends boolean = true> {
|
||||||
youtubeApiKey?: T;
|
youtubeApiKey?: T;
|
||||||
youtubeChannelId?: T;
|
youtubeChannelId?: T;
|
||||||
youtubeMaxItems?: T;
|
youtubeMaxItems?: T;
|
||||||
|
instagramEnabled?: T;
|
||||||
|
instagramAccessToken?: T;
|
||||||
|
instagramUserId?: T;
|
||||||
|
instagramMaxItems?: T;
|
||||||
excludedItems?:
|
excludedItems?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
|
@ -2522,6 +2551,7 @@ export interface SocialFeedSettingsSelect<T extends boolean = true> {
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
youtubeFeedSnapshot?: T;
|
youtubeFeedSnapshot?: T;
|
||||||
|
instagramFeedSnapshot?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
globalType?: T;
|
globalType?: T;
|
||||||
|
|
|
||||||
312
src/utilities/socialFeed/instagramFeed.ts
Normal file
312
src/utilities/socialFeed/instagramFeed.ts
Normal 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 },
|
||||||
|
)
|
||||||
|
|
@ -38,4 +38,98 @@ test.describe('Admin Panel', () => {
|
||||||
const editViewArtifact = page.locator('input[name="email"]')
|
const editViewArtifact = page.locator('input[name="email"]')
|
||||||
await expect(editViewArtifact).toBeVisible()
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { APIRequestContext, Page } from '@playwright/test'
|
import type { APIRequestContext, Page } from '@playwright/test'
|
||||||
|
|
||||||
import { seedTestUser, testUser } from '../helpers/seedUser'
|
import { seedTestUser, testUser } from '../helpers/seedUser'
|
||||||
import { youtubeStubUrlFor } from '../helpers/youtubeStub'
|
import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub'
|
||||||
import { expect, test } from './fixtures/e2eTest'
|
import { expect, test } from './fixtures/e2eTest'
|
||||||
|
|
||||||
const swipeLeft = async (
|
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 youtubeStubUrl = youtubeStubUrlFor(baseURL)
|
||||||
|
const instagramStubUrl = instagramStubUrlFor(baseURL)
|
||||||
|
|
||||||
const setYouTubeStubState = async (request: APIRequestContext, state: 'ok' | 'fail') => {
|
const setYouTubeStubState = async (request: APIRequestContext, state: 'ok' | 'fail') => {
|
||||||
const response = await request.post(`${youtubeStubUrl}/__stub/state`, { data: { state } })
|
const response = await request.post(`${youtubeStubUrl}/__stub/state`, { data: { state } })
|
||||||
expect(response.ok()).toBeTruthy()
|
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 (
|
const updateSocialFeedSettings = async (
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
|
|
@ -633,7 +640,7 @@ test.describe('Campaign home content section', () => {
|
||||||
expect(response.ok()).toBeTruthy()
|
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 (
|
const resetSocialFeedSettings = async (
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
|
|
@ -644,8 +651,13 @@ test.describe('Campaign home content section', () => {
|
||||||
youtubeApiKey: '',
|
youtubeApiKey: '',
|
||||||
youtubeChannelId: '',
|
youtubeChannelId: '',
|
||||||
youtubeMaxItems: 3,
|
youtubeMaxItems: 3,
|
||||||
|
instagramEnabled: true,
|
||||||
|
instagramAccessToken: '',
|
||||||
|
instagramUserId: '',
|
||||||
|
instagramMaxItems: 3,
|
||||||
excludedItems: [],
|
excludedItems: [],
|
||||||
youtubeFeedSnapshot: null,
|
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 ({
|
test('the kill switch pauses the external feed without touching articles', async ({
|
||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
|
|
@ -1079,6 +1309,7 @@ test.describe('Campaign home content section', () => {
|
||||||
await seedTestUser()
|
await seedTestUser()
|
||||||
const headers = await adminHeaders(request)
|
const headers = await adminHeaders(request)
|
||||||
await setYouTubeStubState(request, 'ok')
|
await setYouTubeStubState(request, 'ok')
|
||||||
|
await setInstagramStubState(request, 'ok')
|
||||||
|
|
||||||
const killTag = await createTag(
|
const killTag = await createTag(
|
||||||
request,
|
request,
|
||||||
|
|
@ -1099,17 +1330,23 @@ test.describe('Campaign home content section', () => {
|
||||||
youtubeApiKey: 'e2e-youtube-api-key',
|
youtubeApiKey: 'e2e-youtube-api-key',
|
||||||
youtubeChannelId: 'UCe2eTestChannel',
|
youtubeChannelId: 'UCe2eTestChannel',
|
||||||
youtubeMaxItems: 3,
|
youtubeMaxItems: 3,
|
||||||
|
instagramEnabled: true,
|
||||||
|
instagramAccessToken: 'e2e-ig-token',
|
||||||
|
instagramUserId: '17841400000000000',
|
||||||
|
instagramMaxItems: 3,
|
||||||
excludedItems: [],
|
excludedItems: [],
|
||||||
youtubeFeedSnapshot: null,
|
youtubeFeedSnapshot: null,
|
||||||
|
instagramFeedSnapshot: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// `enabled: false` pauses the whole external board: no cards AND no
|
// `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(
|
await waitForHomeHTML(
|
||||||
request,
|
request,
|
||||||
['E2e Artigo kill switch'],
|
['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('/')
|
await page.goto('/')
|
||||||
const section = page.locator('[data-home-section="contents"]')
|
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.getByRole('link', { name: /E2e Artigo kill switch/ })).toBeVisible()
|
||||||
await expect(section.getByText('E2e Vídeo em destaque')).toHaveCount(0)
|
await expect(section.getByText('E2e Vídeo em destaque')).toHaveCount(0)
|
||||||
await expect(section.getByRole('link', { name: /YouTube →/ })).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 page.goto('about:blank')
|
||||||
await expect(page).toHaveURL(/about:blank/)
|
await expect(page).toHaveURL(/about:blank/)
|
||||||
} finally {
|
} finally {
|
||||||
await setYouTubeStubState(request, 'ok')
|
await setYouTubeStubState(request, 'ok')
|
||||||
|
await setInstagramStubState(request, 'ok')
|
||||||
await cleanupS2Fixtures(request, headers)
|
await cleanupS2Fixtures(request, headers)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
144
tests/e2e/instagram-stub.mjs
Normal file
144
tests/e2e/instagram-stub.mjs
Normal 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}`)
|
||||||
|
})
|
||||||
17
tests/helpers/socialStub.ts
Normal file
17
tests/helpers/socialStub.ts
Normal 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}`
|
||||||
|
}
|
||||||
|
|
@ -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}`
|
|
||||||
}
|
|
||||||
|
|
@ -496,11 +496,11 @@ describe('src/utilities top-level is pinned', () => {
|
||||||
'voteEstimateScenarioFields.ts',
|
'voteEstimateScenarioFields.ts',
|
||||||
'votePledgeData.ts',
|
'votePledgeData.ts',
|
||||||
'votePledgeViews.ts',
|
'votePledgeViews.ts',
|
||||||
// S2 — YouTube Data API v3 feed of the campaign home content board
|
// The campaign home content board's external feeds (YouTube Data API v3
|
||||||
// (fetch + cache + snapshot + formatters). A sibling of `posts.ts`, not a
|
// and Instagram Graph API) live in the `socialFeed/` subfolder — the S2
|
||||||
// domain subfolder: only one external source so far — the Instagram feed
|
// pin predicted it: one external source stayed top-level, the second
|
||||||
// (S3) would earn a `socialFeed/` subfolder instead of a third top-level.
|
// earned the domain folder. Fetch + cache + snapshot + formatters, per
|
||||||
'youtubeFeed.ts',
|
// platform, siblings of `posts.ts`.
|
||||||
])
|
])
|
||||||
|
|
||||||
it('fails the build on an unregistered top-level module', () => {
|
it('fails the build on an unregistered top-level module', () => {
|
||||||
|
|
|
||||||
337
tests/unit/instagramFeed.unit.spec.ts
Normal file
337
tests/unit/instagramFeed.unit.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import type { YouTubeVideo } from '@/utilities/youtubeFeed'
|
import type { YouTubeVideo } from '@/utilities/socialFeed/youtubeFeed'
|
||||||
import {
|
import {
|
||||||
YOUTUBE_MAX_RESULTS_CAP,
|
YOUTUBE_MAX_RESULTS_CAP,
|
||||||
eligibleYouTubeVideos,
|
eligibleYouTubeVideos,
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
parseYouTubeSearchResponse,
|
parseYouTubeSearchResponse,
|
||||||
parseYouTubeVideosResponse,
|
parseYouTubeVideosResponse,
|
||||||
pickThumbnailUrl,
|
pickThumbnailUrl,
|
||||||
} from '@/utilities/youtubeFeed'
|
} from '@/utilities/socialFeed/youtubeFeed'
|
||||||
|
|
||||||
const SEARCH_ITEM = (videoId: string, title: string, publishedAt: string, extra?: object) => ({
|
const SEARCH_ITEM = (videoId: string, title: string, publishedAt: string, extra?: object) => ({
|
||||||
id: { videoId },
|
id: { videoId },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue