feat(S11): feed do Instagram configurado não aparece na home — painel de status da sincronização no admin

O fail-closed silencioso da S3 deixava a assessoria cega: token errado não deixava
rastro além do picker vazio. Fim da caixa-preta:

- `instagramSyncStatus` (jsonb hidden) na global `social-feed-settings`, escrito pelo
  render, pelo afterChange (sync awaited quando a credencial IG muda) e pela rota
  `POST /api/social-feed/sync` (botão, auth admin + same-origin).
- `InstagramApiError` + `describeInstagramError`: o fetch parseia o body de erro da
  Graph API e o mapeia para copy pt-BR com a correção (OAuthException/expired →
  "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login";
  "Invalid user id" → confira o ID; 5xx → indisponível; TypeError → rede; AbortError →
  demorou demais). O ID só casa com "user" E "id" juntos (token rejections real falam
  "the user must be logged in" — não podem virar a branch de ID).
- `InstagramSyncStatusPanel` (campo ui irmão do picker): 5 estados fiéis ao rascunho;
  gate respeita o master `enabled`; `outcome` transiente limpo ao editar credencial;
  aria-live nos boxes interativos. Home inalterada (fail-closed intacto).
- Bug real: `afterChange` roda dentro da transação do save (row lock até os hooks
  retornarem); persistir o status via pool numa 2ª conexão deadlockava no lock da
  própria linha (~80-230s/save). Fix: persists do sync-hook na MESMA transação via
  `getPostgresTransactionDatabase` (C6); render/rota sem transação seguem no pool.
  Diagrama: UPDATE transactionid Lock vs idle in transaction; fix verificado 45ms vs 232s.
- Stub IG ganha estado `invalid-token`; runbook docs/ops/instagram-feed-token-runbook.md;
  migration add_instagram_sync_status (jsonb nullable).
- Débitos registrados: #124 (janela de lock) e #125 (polish P3).
- Gates: unit 2463 + int 791; lint/format/tsc/cycles; build; e2e admin 5/5 + frontend serial 19/19.
This commit is contained in:
Francisco Solla 2026-08-20 00:14:32 -03:00
parent dba75ff886
commit 3ce5a69555
22 changed files with 15073 additions and 44 deletions

View file

@ -81,6 +81,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/Instagram 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`.
- **Instagram sync status (S11):** the global's `instagramSyncStatus` jsonb (hidden) is written by the render path, by `afterChange` (re-sync only when IG credentials changed), and by the admin button `POST /api/social-feed/sync` (admin session via `payload.auth` + same-origin guard); the `InstagramSyncStatusPanel` ui field renders it. Runbook de credenciais: `docs/ops/instagram-feed-token-runbook.md`.
- **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.

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
--- ---
**S11** — o feed do Instagram configurado **sem aparecer na home** fica diagnosticável: fim do fail-closed silencioso da S3. A assessoria vê, na mesma global onde preenche o token, o estado da sincronização (`instagramSyncStatus` jsonb hidden na `social-feed-settings`) e o **porquê** de os posts não aparecerem — o painel `InstagramSyncStatusPanel` (campo `ui` irmão do picker) mostra 5 estados fiéis ao rascunho aprovado (desativado / ainda não configurado / falha com motivo em linguagem de produto + "Tentar sincronizar de novo" / sincronizado · há X min · N posts / aguardando 1ª sync). A causa da ocorrência de produção (board vazio com token+ID preenchidos) é agora confirmável em 1 clique após o deploy. Engenharia: `loadInstagramFeed` passou a parsear o body de erro da Graph API e lançar `InstagramApiError` (`status` + `{message,type}`); `describeInstagramError` mapeia para copy pt-BR com a correção — OAuthException/expired → "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login" (o refresh endpoint da Graph API só renova tokens de Instagram Login), "Invalid user id" → "confira o ID da conta Business/Creator", 5xx → indisponível, `TypeError` → rede. Estado escrito por 3 caminhos: render da home (`getInstagramFeed`, dentro do `unstable_cache`, SQL direto sem hooks — padrão anti-`revalidateTag`-no-cache da S2), `afterChange` da global (sync **awaited** quando a credencial IG muda — `instagramCredentialsChanged(doc, previousDoc)`, gate que pula save de exclusões/YouTube; o sync é importado lazy no hook para o `payload.config.ts` não puxar o módulo do feed no load, que é o que todo spec int importa) e a rota `POST /api/social-feed/sync` (botão; `payload.auth` sessão admin exigindo `users` + `isSameOriginRequest` CSRF — allowlistada no spec de convenção de POST routes com razão, é auth de admin ≠ envelope `/campanha`). **Bug real descoberto e corrigido na entrega:** o `afterChange` roda DENTRO da transação do save (a row lock fica presa até os hooks retornarem), então escrever o status/snapshot via pool numa segunda conexão **deadlockava no lock da própria linha** (~80-230s por save, transações-zumbi segurando o lock). A correção roteia os persists do sync-hook pela **mesma transação** (`getPostgresTransactionDatabase` — o helper transacional da C6) com um `req` opcional no `syncInstagramFeed`; o render e a rota sem transação aberta seguem no pool. Diagnóstico reproduzido em `pg_stat_activity` (UPDATE `transactionid` Lock vs `idle in transaction`) e fix verificado (45ms vs 232s). Stub IG ganha estado `invalid-token` (400 OAuthException) para o e2e da cena de falha; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra, validação via curl `/me` e `refresh_access_token`, geração pelo Instagram Login, o que NÃO fazer). Migration `20260820_003426_add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva). **/simplify (3 reviewers):** absorvi a correção P1 de diagnóstico — o mapeamento de ID exigia só "user" (regex frouxo) e mensagens reais de token OAuthException ("the **user** must be logged in", "user changed password") caíam na branch de ID, mostrando a razão errada; passei a exigir "user" E "id" juntos (`isInvalidUserIdMessage`) e adicionei o mapeamento de `AbortError` (timeout → "demorou demais"). Painel: gate passou a respeitar o master `enabled` (master off → "Instagram desativado", nunca "Sincronizado no board"), rótulo do botão no estado de primeira sync ("Tentar sincronizar" vs "de novo"), `outcome` transiente limpo ao editar credencial e `aria-live` nos boxes interativos. Testes: caminho transacional do `syncInstagramFeed` (persist na mesma transação) + fallback pool cobertos por unit; e2e do painel ganhou as cenas fail-500 e "desativado"; cleanup do stub global virou `afterEach`. Débitos registrados: #124 (janela de lock: fetch de rede no sync-hook segura a row lock até 10s, acoplando a home ao save) e #125 (polish P3 — tipo de persist duplicado, rota 200 vs 400, last-writer-wins stale, `5 posts` hardcoded). Verificação: unit 2463 + int 791 verdes; lint/format/tsc/cycles verdes; `pnpm build` ok; e2e local — smoke admin 5/5 (painel ok → falha token → retry → falha 500 → desativado → não configurado) + describe serial frontend 19/19.
**C143** — demandas visíveis apenas para responsáveis explícitos + candidato/coordenador. Novo campo `campaignDemand.responsibles` (hasMany → `campaignUser`, staff elegível via `eligibleCampaignStaffWhere` com hook `beforeValidate` que descarta ids não-staff, fail-closed) e regra de leitura/atualização por membership: assessor só vê demandas onde é responsável (`{ responsibles: { contains } }`), candidato/coordenador/admin sempre, leader nunca — a regra substitui o escopo municipal de demandas em `src/utilities/access/demands.ts` e todas as superfícies (lista, URL, busca global, demandas de atividade, C90) herdam via `overrideAccess: false`; criação sem responsáveis fica visível só a unrestricted (fail-closed). Criador entra automaticamente como responsável no hook `enforceDemandWorkflow` (cobre form, wizard e rascunhos C90); migration `20260819_233503_add_campaign_demand_responsibles` adiciona `campaign_user_id` a `campaign_demand_rels` (FK cascade) e faz backfill de `created_by_id` → responsável para demandas pré-C143. UI: picker multi-select `DemandResponsibleMultiSelect` (chips com avatar, chip do criador rotulado "(criador)" e travado na criação, sugestões = assessores do município) na criação (`/demandas/nova` em 2 colunas + wizard "Registrar pedido" empilhado) e card de gestão `DemandResponsiblesCard` no detalhe (add/remove com save full-replace + advisory lock, rótulo "Visível para: Responsáveis · Candidato · Coordenador"); busca por `searchDemandResponsibleOptions` (query curta → assessores do município; senão → staff por nome). Testes: int reescritos para a regra nova (workflow/homeSearch) + 4 casos novos (membership, fail-closed sem responsáveis, gestão add/remove/self-removal, responsável fora da carteira, descarte de id não-staff); e2e novo `campaignDemandVisibility.e2e.spec.ts` (assessor do mesmo município não vê lista/URL; hand-off de acesso add/revoke pela UI); fixture e2e ganhou `createCampaignDemand`. Verificação: unit 2444 + int 795 verdes, e2e 5/5 local (visibility + wizard + demanda do municipalidades), lint/format/knip/cycles/tsc/build limpos; importMap regenerado com `S3_*` dummy (OPS69). Nota de deploy: conferir na primeira aplicação em prod `SELECT count(*) FROM campaign_demand_rels WHERE path='responsibles'` = nº de demandas com `created_by_id`. Plano de implementação: `docs/plans/demandas-responsaveis-impl.md`. **C143** — demandas visíveis apenas para responsáveis explícitos + candidato/coordenador. Novo campo `campaignDemand.responsibles` (hasMany → `campaignUser`, staff elegível via `eligibleCampaignStaffWhere` com hook `beforeValidate` que descarta ids não-staff, fail-closed) e regra de leitura/atualização por membership: assessor só vê demandas onde é responsável (`{ responsibles: { contains } }`), candidato/coordenador/admin sempre, leader nunca — a regra substitui o escopo municipal de demandas em `src/utilities/access/demands.ts` e todas as superfícies (lista, URL, busca global, demandas de atividade, C90) herdam via `overrideAccess: false`; criação sem responsáveis fica visível só a unrestricted (fail-closed). Criador entra automaticamente como responsável no hook `enforceDemandWorkflow` (cobre form, wizard e rascunhos C90); migration `20260819_233503_add_campaign_demand_responsibles` adiciona `campaign_user_id` a `campaign_demand_rels` (FK cascade) e faz backfill de `created_by_id` → responsável para demandas pré-C143. UI: picker multi-select `DemandResponsibleMultiSelect` (chips com avatar, chip do criador rotulado "(criador)" e travado na criação, sugestões = assessores do município) na criação (`/demandas/nova` em 2 colunas + wizard "Registrar pedido" empilhado) e card de gestão `DemandResponsiblesCard` no detalhe (add/remove com save full-replace + advisory lock, rótulo "Visível para: Responsáveis · Candidato · Coordenador"); busca por `searchDemandResponsibleOptions` (query curta → assessores do município; senão → staff por nome). Testes: int reescritos para a regra nova (workflow/homeSearch) + 4 casos novos (membership, fail-closed sem responsáveis, gestão add/remove/self-removal, responsável fora da carteira, descarte de id não-staff); e2e novo `campaignDemandVisibility.e2e.spec.ts` (assessor do mesmo município não vê lista/URL; hand-off de acesso add/revoke pela UI); fixture e2e ganhou `createCampaignDemand`. Verificação: unit 2444 + int 795 verdes, e2e 5/5 local (visibility + wizard + demanda do municipalidades), lint/format/knip/cycles/tsc/build limpos; importMap regenerado com `S3_*` dummy (OPS69). Nota de deploy: conferir na primeira aplicação em prod `SELECT count(*) FROM campaign_demand_rels WHERE path='responsibles'` = nº de demandas com `created_by_id`. Plano de implementação: `docs/plans/demandas-responsaveis-impl.md`.
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do OPS70/#87 cobre; restaurado na entrega com envs dummy). **OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do OPS70/#87 cobre; restaurado na entrega com envs dummy).

View file

@ -0,0 +1 @@
**S11** — o feed do Instagram configurado **sem aparecer na home** fica diagnosticável: fim do fail-closed silencioso da S3. A assessoria vê, na mesma global onde preenche o token, o estado da sincronização (`instagramSyncStatus` jsonb hidden na `social-feed-settings`) e o **porquê** de os posts não aparecerem — o painel `InstagramSyncStatusPanel` (campo `ui` irmão do picker) mostra 5 estados fiéis ao rascunho aprovado (desativado / ainda não configurado / falha com motivo em linguagem de produto + "Tentar sincronizar de novo" / sincronizado · há X min · N posts / aguardando 1ª sync). A causa da ocorrência de produção (board vazio com token+ID preenchidos) é agora confirmável em 1 clique após o deploy. Engenharia: `loadInstagramFeed` passou a parsear o body de erro da Graph API e lançar `InstagramApiError` (`status` + `{message,type}`); `describeInstagramError` mapeia para copy pt-BR com a correção — OAuthException/expired → "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login" (o refresh endpoint da Graph API só renova tokens de Instagram Login), "Invalid user id" → "confira o ID da conta Business/Creator", 5xx → indisponível, `TypeError` → rede. Estado escrito por 3 caminhos: render da home (`getInstagramFeed`, dentro do `unstable_cache`, SQL direto sem hooks — padrão anti-`revalidateTag`-no-cache da S2), `afterChange` da global (sync **awaited** quando a credencial IG muda — `instagramCredentialsChanged(doc, previousDoc)`, gate que pula save de exclusões/YouTube; o sync é importado lazy no hook para o `payload.config.ts` não puxar o módulo do feed no load, que é o que todo spec int importa) e a rota `POST /api/social-feed/sync` (botão; `payload.auth` sessão admin exigindo `users` + `isSameOriginRequest` CSRF — allowlistada no spec de convenção de POST routes com razão, é auth de admin ≠ envelope `/campanha`). **Bug real descoberto e corrigido na entrega:** o `afterChange` roda DENTRO da transação do save (a row lock fica presa até os hooks retornarem), então escrever o status/snapshot via pool numa segunda conexão **deadlockava no lock da própria linha** (~80-230s por save, transações-zumbi segurando o lock). A correção roteia os persists do sync-hook pela **mesma transação** (`getPostgresTransactionDatabase` — o helper transacional da C6) com um `req` opcional no `syncInstagramFeed`; o render e a rota sem transação aberta seguem no pool. Diagnóstico reproduzido em `pg_stat_activity` (UPDATE `transactionid` Lock vs `idle in transaction`) e fix verificado (45ms vs 232s). Stub IG ganha estado `invalid-token` (400 OAuthException) para o e2e da cena de falha; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra, validação via curl `/me` e `refresh_access_token`, geração pelo Instagram Login, o que NÃO fazer). Migration `20260820_003426_add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva). **/simplify (3 reviewers):** absorvi a correção P1 de diagnóstico — o mapeamento de ID exigia só "user" (regex frouxo) e mensagens reais de token OAuthException ("the **user** must be logged in", "user changed password") caíam na branch de ID, mostrando a razão errada; passei a exigir "user" E "id" juntos (`isInvalidUserIdMessage`) e adicionei o mapeamento de `AbortError` (timeout → "demorou demais"). Painel: gate passou a respeitar o master `enabled` (master off → "Instagram desativado", nunca "Sincronizado no board"), rótulo do botão no estado de primeira sync ("Tentar sincronizar" vs "de novo"), `outcome` transiente limpo ao editar credencial e `aria-live` nos boxes interativos. Testes: caminho transacional do `syncInstagramFeed` (persist na mesma transação) + fallback pool cobertos por unit; e2e do painel ganhou as cenas fail-500 e "desativado"; cleanup do stub global virou `afterEach`. Débitos registrados: #124 (janela de lock: fetch de rede no sync-hook segura a row lock até 10s, acoplando a home ao save) e #125 (polish P3 — tipo de persist duplicado, rota 200 vs 400, last-writer-wins stale, `5 posts` hardcoded). Verificação: unit 2463 + int 791 verdes; lint/format/tsc/cycles verdes; `pnpm build` ok; e2e local — smoke admin 5/5 (painel ok → falha token → retry → falha 500 → desativado → não configurado) + describe serial frontend 19/19.

View file

@ -0,0 +1,71 @@
# Runbook — Feed do Instagram: validar/gerar o token (S11)
Issue: #115 · Entrega: S11 (painel de status da sincronização no admin)
## O que o painel mostra
Na global **Configurações → Feed de redes sociais**, o bloco "Instagram — estado
da sincronização" diz o estado da última tentativa:
| Estado | Significado | Ação |
| --------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| **Sincronizado · há X min · N posts** | Credenciais aceitas; posts no board "Acompanhe de perto" | Nenhuma |
| **Falha na última sincronização** | A Graph API recusou a última tentativa; o texto diz o motivo e a correção | Corrigir conforme o motivo e clicar **Tentar sincronizar de novo** |
| **Instagram ainda não configurado** | Falta token ou ID | Preencher os dois campos |
| **Aguardando a primeira sincronização** | Configurado, nenhuma tentativa ainda | Clicar **Tentar sincronizar de novo** |
O estado é atualizado no salvar (quando as credenciais mudam), em cada render da
home (cache de 5 min) e no clique do botão. A home pública nunca quebra quando a
API falha — o board segue com artigos + YouTube.
## Diagnóstico da ocorrência de 2026-08-19 (causa provável)
Produção mostrava picker vazio e nenhum card IG com token + ID preenchidos — o
fail-closed silencioso da S3 não dizia o porquê. A causa mais provável, na ordem:
1. **Token emitido via Facebook Login** (page token) — a `graph.instagram.com`
recusa; o refresh automático não renova (o endpoint `refresh_access_token`
só aceita tokens do Instagram Login).
2. **Token expirado/revogado**.
3. **ID do usuário errado** (não é o da conta Business/Creator vinculada).
**Confirmação em 1 clique:** no admin, **Tentar sincronizar de novo** — o painel
mostra o motivo exato devolvido pela API. Se a causa for a 1, aparece a mensagem
de token recusado com a correção; se for a 3, a mensagem de ID não reconhecido.
## Validar um token sem o admin
```bash
# O endpoint /me responde com o username e o tipo de conta se o token é válido
# para a Graph API do Instagram (Business/Creator):
curl -s "https://graph.instagram.com/me?fields=user_id,username&access_token=SEU_TOKEN"
# Teste o refresh (só tokens emitidos pelo Instagram Login passam):
curl -s "https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=SEU_TOKEN"
```
- `/me` com `OAuthException` → token inválido/expirado ou emitido via
**Facebook Login** (a Graph API do Instagram só aceita tokens do Instagram
Login).
- `/me` ok mas o ID configurado não bate com `user_id` → corrigir o campo
"ID do usuário".
## Gerar um token de longa duração (Instagram Login)
1. Acessar `developers.facebook.com` com a conta que administra a página →
criar/usar um app do tipo **Business** (qualquer tipo serve para o fluxo de
negócios, mas o app precisa estar em modo Live para uso fora do sandbox).
2. Produto **Instagram** → **Instagram Login with Basic Display** → **Gerar
token** com a conta Business/Creator do @depjorgesolla.
3. Trocar o token de curta duração pelo de longa duração usando o
**Long-Lived Token Exchange** da documentação do Instagram Basic Display
(ou usar o endpoint `refresh_access_token` do passo anterior após a troca).
4. Colar o token e o ID numérico da conta no admin e clicar
**Tentar sincronizar de novo**.
## O que NÃO fazer
- Não usar token da API Graph do Facebook (página) — o app recusa.
- Não colar o token em arquivos, Issues ou chat — o campo é admin-only e deve
continuar.
- Não zerar o token para "testar" — o painel de status dispensa tentativa-e-erro.

View file

@ -0,0 +1,123 @@
# Impl: Feed do Instagram configurado não aparece na home pública
Status: aprovado
Atualizado em: 2026-08-19
Issue: #115
Intenção: docs/plans/feed-instagram-nao-aparece-home.md
Appetite restante: ~1 dia eng
## Leitura da intenção
- **Outcome:** a assessoria vê, na própria global "Feed de redes sociais", o estado da sincronização do Instagram — "Sincronizado · há X min · N posts", "Falha na última sincronização" com o motivo em linguagem de produto e a correção esperada, ou "Instagram ainda não configurado" — e tem um botão "Tentar sincronizar de novo" para feedback imediato. O board público permanece fail-closed como hoje (artigos + YouTube quando o IG falha); a home pública não muda em nada.
- **O que NÃO negociar:** painel de status por plataforma na própria global, só estado atual + motivo (sem histórico, sem alertas, sem dashboard); sem wizard OAuth; sem embeds no site público; token nunca exposto fora do admin (`read` admin-only preservado); copy em pt; identificadores em inglês; a home pública nunca quebra nem vaza erro.
- **O que reavaliar:** a hipótese da intenção apontava `InstagramPostExclusionPicker` como superfície do status — o picker é a lista de exclusão (S3) e **fica intacto**; o painel de status é um **irmão** (`InstagramSyncStatusPanel`), mesma família de componente `admin.components`, mesmo padrão do rascunho (caixa de status separada da lista). `revalidateRequest.ts` realmente não muda (a tag `social-feed` já cobre). O diagnóstico da ocorrência em produção é impossível deste worktree (sem acesso ao DB de prod — `PROD_DATABASE_URL` não existe localmente): o próprio painel vira o instrumento de diagnóstico (1 clique após o deploy confirma a causa), e o runbook documenta validação/geração de token.
## Abordagem recomendada
```mermaid
flowchart LR
A[Global SocialFeedSettings<br/>+ instagramSyncStatus jsonb] --> H[afterChange<br/>revalidate + sync se credencial mudou]
H --> S[syncInstagramFeed<br/>load + snapshot + status]
B[Botão Tentar sincronizar] --> R[POST /api/social-feed/sync<br/>auth admin + same-origin]
R --> S
C[Render home<br/>getInstagramFeed · unstable_cache] -->|sucesso| P[persist snapshot + status<br/>SQL cru, sem hooks]
C -->|falha| Q[persist status erro +<br/>fallback snapshot]
S --> P
S --> Q
P --> G[(social_feed_settings)]
Q --> G
G --> PANEL[InstagramSyncStatusPanel<br/>ui field · 5 estados]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — estado da sincronização **persistido** numa coluna `jsonb` da própria global (escrito por render, hook e botão, todos pelo mesmo módulo `instagramSync.ts`); painel admin como campo `ui` irmão do picker; rota `POST /api/social-feed/sync` (auth de sessão admin + `isSameOriginRequest`) para o botão; erro da API mapeado para copy de produto (`InstagramApiError` + `describeInstagramError`). O caminho de render ganha apenas a persistência do status dentro do try/catch existente — zero mudança visual na home, fail-closed intacto.
**Rejeitadas:** B) estado derivado em runtime (o painel admin não renderiza a home — sem estado não há como mostrar nada; e a intenção exige o estado da **última** sincronização, que precisa sobreviver a restart) | C) server action no botão (primeiro uso sem precedente no shell do admin do Payload; rota HTTP é previsível, testável e segue o precedente de `/api/revalidate`).
### Decisões de engenharia
1. **Estado persistido como `instagramSyncStatus` (jsonb, campo hidden) — uma coluna, shape `{ lastSyncAt?, postCount?, error?, errorAt? }`.**
Opções: A) uma coluna jsonb (padrão do `instagramFeedSnapshot`) | B) 3 colunas (`lastSyncAt`, `postCount`, `lastError`) | C) estado em memória/cache.
Recomendação: A — o snapshot já é jsonb escrito por SQL cru; uma coluna nova espelha o padrão; o shape é autodescritivo e muda sem migration futura. `lastSyncAt`/`postCount` são da última sincronização **bem-sucedida**; `error`/`errorAt` da última **falha** (falha após sucesso preserva os campos de sucesso? **não** — o painel em falha mostra só o erro, cena 1 do rascunho; overwrite é mais simples e sem leitura-modificação-escrita).
Rejeitadas: B 3 colunas = 3 DDLs para o mesmo dado; C não sobrevive a restart e o painel admin não compartilha o render.
→ **Migration:** `pnpm migrate:create add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva; revisar o DDL gerado — precedente S2/S3 de drift em migrações hand-written).
2. **`InstagramApiError` (status + apiMessage) no `loadInstagramFeed` + `describeInstagramError` para copy de produto.**
Opções: A) o fetch parseia o body de erro da Graph API (`{ error: { message, type } }`) e lança erro tipado; `describeInstagramError` mapeia para pt-BR com a correção | B) mensagem genérica "falhou com status N" (status quo) | C) mapa estático por status.
Recomendação: A — o produto exige o "porquê" (token recusado vs ID inválido vs rede); o body da Graph API distingue OAuthException (token inválido/expirado/emitido via Facebook Login — copy do draft) de "Invalid user id"; status 5xx = API indisponível; `TypeError` de fetch = rede. O mesmo `describeInstagramError` serve render, hook e rota (uma spelling).
Rejeitadas: B é o bug atual (assessoria cega); C não lê o body e não cobre casos reais.
- Mapeamentos: `400|401|403` + OAuthException/`expired|invalid|revoked` → "O token do Instagram foi recusado pela Graph API — está inválido/expirado ou foi emitido via Facebook Login (a Graph API só aceita tokens gerados pelo Instagram Login). Gere um novo token de longa duração pelo Instagram Login e atualize o campo acima."; `400` + menção a user/id → "O ID do usuário não foi reconhecido pela Graph API. Confira se é o ID numérico da conta Business/Creator."; `5xx`/outros → "A Graph API está indisponível no momento (status N). Tente novamente em alguns minutos."; `TypeError` de fetch → "Não foi possível falar com a API do Instagram (rede indisponível)."; desconhecido → "Erro inesperado ao sincronizar o Instagram."
3. **Rota `POST /api/social-feed/sync` para o botão — auth de sessão admin + CSRF same-origin.**
Opções: A) route handler em `(frontend)/api/social-feed/sync` com `payload.auth({ headers })` exigindo `collection === 'users'` + `isSameOriginRequest` (helper já existente) | B) server action | C) rota sem auth (confia no botão).
Recomendação: A — o precedente `sameOriginRequest.ts` é literalmente para "cookie-authenticated JSON route handlers"; `payload.auth` lê o cookie `payload-token` da sessão admin (precedente `campaignAuth`); a rota vive ao lado de `/api/revalidate` (grupo `(frontend)` — route handlers não passam por layouts; o URL é `/api/social-feed/sync`). Retorna `{ ok, status }`; status `null`/400 quando IG não configurado (o botão nem renderiza nesse estado — defesa).
Rejeitadas: B sem precedente no shell admin (risco de incompatibilidade com o RSC do Payload, custo de spike); C permite abuso do rate da Graph API.
→ Depois do sync, a rota chama `revalidateTag('social-feed')` (fora de cache — permitido).
4. **Sync no `afterChange` da global quando a credencial IG muda (awaited, bounded, à prova de erro).**
Opções: A) hook `afterChange` [revalidateFeed, syncInstagramAfterChange] — sync **awaited** quando `doc` ≠ `previousDoc` em `instagramAccessToken`/`instagramUserId`/`instagramEnabled` (false→true); `AbortSignal.timeout(10_000)` no fetch; try/catch total — erro de sync nunca quebra o save | B) sync em todo save | C) sem hook — só botão e render.
Recomendação: A — o fluxo da intenção é "preenche token + ID e salva → o admin mostra o estado": com o sync awaited, o reload da global após o salvar já mostra o resultado **com as credenciais novas** (o estado antigo mostraria a falha da credencial antiga — confuso e o problema do status quo). Gate por mudança: save de exclusões/YouTube não gasta chamada de API nem adiciona latência. Timeout: Graph API responde em <1s mesmo em erro; 10s cap em API pendurada.
Rejeitadas: B latência e custo em todo save sem ganho; C deixa o fluxo principal da intenção em dois cliques e o estado inicialmente mentiroso.
→ O hook usa o MESMO `syncInstagramFeed` da rota (uma implementação, dois call sites).
5. **Painel `InstagramSyncStatusPanel` (campo `ui`, irmão do picker) — 5 estados.**
Opções: A) componente client próprio lendo os campos do form via `useAllFormFields` + estado local pós-botão | B) estender o picker | C) campo `json` não-hidden renderizado cru.
Recomendação: A — o picker prova o padrão (campo `ui` + componente client + `useAllFormFields`); o painel lê `instagramSyncStatus` (inicial), `instagramAccessToken`/`instagramUserId`/`instagramEnabled` e mantém estado local só para o pós-clique (o status já está persistido no servidor — o form não precisa ser modificado). Estados: (1) `instagramEnabled === false` → "Instagram desativado — os posts não aparecem no board."; (2) sem token ou sem ID → "Instagram ainda não configurado" + explicação (cena 3); (3) `error` → "Falha na última sincronização" + mensagem + botão "Tentar sincronizar de novo" (cena 1); (4) `lastSyncAt` → "Sincronizado · há X min · N posts" + "Próxima atualização automática em ~5 min. Os N posts mais recentes estão no board 'Acompanhe de perto' da home." (cena 2, sem botão — fiel ao rascunho); (5) configurado sem status → "Aguardando a primeira sincronização" + botão. Pós-clique: `pending` ("Sincronizando…") → sucesso cena 2 / falha cena 1 com o motivo retornado. Tempo relativo com `Intl.RelativeTimeFormat('pt-BR')` local (o painel é client; `formatRelativePostDate` é server-only — `posts.ts` abre com `import 'server-only'`).
Rejeitadas: B mistura duas responsabilidades no picker (o picker é lista de exclusão; o status é estado operacional — o rascunho os desenha como caixas separadas); C expõe JSON cru (anti-produto).
→ **UI:** Impeccable C — encaixe na linguagem do admin (caixas zinc/red do draft, `text-sm`, bordas `rounded-lg`), shape → craft → critique → polish no navegador (admin, 3 cenas + estados).
6. **`loadInstagramFeed` ganha `signal?: AbortSignal` (repasse ao `fetchImpl`).**
O hook precisa do deadline; o `LoadInstagramFeedArgs` aceita `init` no fetchImpl — o signal flui sem mudar a assinatura do fetch. Unit-testado.
7. **Stub IG ganha estado `invalid-token` (400 com body `{ error: { type: 'OAuthException', ... } }`).**
A cena 1 do draft é literalmente esse erro; o e2e do painel precisa exercitá-lo (o estado `fail` atual responde 500 genérico). O painel de falha com a copy do token é o aceite central do item.
### Componentes / mudanças
- **`InstagramApiError` + `parseInstagramErrorBody`** (`src/utilities/socialFeed/instagramFeed.ts`, estender): parseia body de erro em resposta não-2xx, lança `InstagramApiError` (`status` + `apiMessage`); `loadInstagramFeed` aceita `signal` opcional repassado ao `fetchImpl` (fetch do usuário, media e refresh).
- **`src/utilities/socialFeed/instagramSync.ts`** (novo, `server-only`, dentro do subfolder `socialFeed/` — a pin de top-level da `codebaseConventions.unit.spec.ts` não se aplica a subfolders, o comentário já cobre o domínio):
- `InstagramSyncStatus` (`{ lastSyncAt?: string; postCount?: number; error?: string; errorAt?: string }`).
- `describeInstagramError(cause: unknown): string` — mapeamentos da Decisão 2 (puro, exportado p/ unit).
- `successInstagramSyncStatus(postCount)` / `failedInstagramSyncStatus(error)` — builders puros.
- `persistInstagramSyncStatus(payload, status)` — SQL cru UPDATE+INSERT (mesmo padrão/precondição do snapshot; rodado DENTRO de `unstable_cache` no render — sem hooks).
- `syncInstagramFeed(payload, { signal? }): Promise<{ ok: true; status } | { ok: false; status }>` — lê a global, retorna "não configurado" sem tocar na API quando `enabled === false`/`instagramEnabled === false`/sem token/ID; sucesso → `persistInstagramSnapshot` + persist token renovado (reuso dos privados? **não** — os `persist*` atuais são module-private do `instagramFeed.ts`; exportar `persistInstagramSnapshot`/`persistInstagramAccessToken` do `instagramFeed.ts` para o sync module — o dono do persist é o feed, o sync é o orquestrador) + status de sucesso; falha → status de erro via `describeInstagramError`; nunca lança (status é o resultado).
- **`getInstagramFeed`** (`instagramFeed.ts`, estender): no try (sucesso) persiste também o status de sucesso; no catch persiste o status de erro ANTES do fallback snapshot (a ordem não importa para o board; importa para o painel). Nada mais muda no caminho público.
- **`SocialFeedSettings`** (`src/globals/SocialFeedSettings.ts`, estender): campo `instagramSyncStatus` (json, `admin.hidden: true` — o painel renderiza, o dado não aparece cru); campo `ui` `instagramSyncStatusPanel` (após `instagramEnabled`, antes do token — status no topo do bloco IG, fiel ao rascunho; `admin.components.Field` → painel); `afterChange` = `[revalidateFeed, syncInstagramAfterChange]` (gate da Decisão 4, try/catch total). Copy do campo token/`admin.description`: ajuste de 1 linha para apontar o painel de status ("veja o estado da sincronização abaixo/ao lado") — opcional, não bloquear.
- **`src/app/(frontend)/api/social-feed/sync/route.ts`** (novo): `POST`; `isSameOriginRequest` (401 se cross-origin); `payload.auth({ headers })` exigindo `user.collection === 'users'` (401 sem sessão admin); lê a global, IG desconfigurado → `400 { ok: false, error }`; `syncInstagramFeed` com `AbortSignal.timeout(10_000)`; `revalidateTag(REVALIDATE_SOCIAL_FEED_TAG)`; `200 { ok, status }`. Erro interno → `500` (sem stack).
- **`src/components/admin/InstagramSyncStatusPanel.tsx`** (novo, `'use client'`): estados da Decisão 5; fetch para a rota no clique; `aria-busy` no pending; sem escrita no form (status é persistido no servidor).
- **`tests/e2e/instagram-stub.mjs`** (estender): estado `'invalid-token'` → `400 { error: { type: 'OAuthException', message: 'Error validating access token' } }` em `/user` e `/media` (e refresh → 400); `POST /__stub/state` aceita o novo estado.
- **Migration:** `add_instagram_sync_status` (jsonb nullable). **Types/importmap:** `pnpm generate:types` + `pnpm generate:importmap` (com envs `S3_*` dummy setadas — OPS69). **Access/Consent:** sem consent (estado operacional interno, não PII); global segue admin-only; a rota exige sessão admin. **UI:** Impeccable C (encaixe no admin; shape → craft → critique → polish).
### Dados → forma
- O painel mostra **estado operacional**, não métrica: contagem de posts sincronizados (cru, da API), tempo desde a última boa, motivo da última falha. Nada de histórico, alertas ou engajamento (corte da intenção). Forma: caixas do rascunho (verde/zinc neutro sucesso, red falha, zinc neutro não-configurado) — contraste e semântica visuais primeiro, sem cor inventada.
## Fases verificáveis
1. **Schema + servidor** (~metade do appetite): migration + `generate:types`; `InstagramApiError` + parse do body + `signal` no `loadInstagramFeed`; `instagramSync.ts` (status builders + `describeInstagramError` + persist SQL + `syncInstagramFeed`); persistência de status no `getInstagramFeed`; rota `/api/social-feed/sync`. Unit tests: `describeInstagramError` (OAuthException→token, invalid-user→ID, 5xx→indisponível, TypeError→rede, unknown→inesperado), `loadInstagramFeed` com fetchImpl 400+body de erro lançando `InstagramApiError`, repasse de `signal`, builders de status, gate `instagramCredentialsChanged` (doc/previousDoc: token mudou, ID mudou, enabled false→true, exclusão não dispara). Gates parciais: tsc + `pnpm test`.
2. **Painel + hook + runbook** (restante): `InstagramSyncStatusPanel` + campo `ui` + hook `afterChange` com sync awaited; stub `invalid-token`; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra; validação do token via refresh/me com curl; geração pelo Instagram Login vs Facebook Login; a provável causa da ocorrência atual e como o painel confirma em 1 clique pós-deploy); alinhar a copy do vazio do picker com o rascunho ("Nenhum post sincronizado. Após uma sincronização bem-sucedida, os últimos posts aparecem aqui…"); `generate:importmap` + AGENTS.md (1 linha: painel de status + rota de sync na seção do `social-feed`). Polish no navegador (admin, 3 cenas).
3. **e2e + gates finais**: admin smoke — seed com token+ID → painel "Aguardando a primeira sincronização" → clique → "Sincronizado" com contagem + assert do `instagramSyncStatus` via REST; stub `invalid-token` → falha com copy do token + botão; stub `fail` → falha 500; reset do helper `resetSocialFeedSettings` ganha `instagramSyncStatus: null` (determinismo do serial); smoke do picker existente deve continuar verde (o hook passa a sincronizar no seed com credenciais — stub ok, determinístico). Gates: `pnpm gate:fast`, `pnpm test`, `pnpm build` local, e2e no CI; changelog `docs/changelog/2026-08-19-s11.md` + `pnpm changelog:build`.
## Rabbit holes / Não escopo (engenharia)
- Não sincronizar no salvar de exclusões/YouTube (gate por credencial — Decisão 4).
- Não fazer histórico/alerta/dashboard de saúde; não tocar no board público nem nos cards.
- Não mexer no fluxo de refresh de token existente (o `loadInstagramFeed` já refresca e persiste; o sync só reporta).
- Não paginar o feed, não buscar stories/métricas.
- Não backfill de status retroativo (a primeira sincronização após o deploy escreve o estado).
- Não acesso ao DB de prod para o diagnóstico (fora do alcance do worktree); o painel + runbook cobrem o aceite.
- Não mudar a pin de `codebaseConventions` (o módulo novo fica no subfolder `socialFeed/` já documentado).
## Riscos e mitigação
- **Rota auth no admin:** `payload.auth({ headers })` com cookie `payload-token` — precedente `campaignAuth`; sem sessão → 401 silencioso (o painel mostra o erro como falha de sincronização? **não** — o painel trata 401 como "sessão expirada": recarrega a página para o admin re-autenticar; copy honesta). e2e cobre.
- **Hook sync com latência no save:** cap `AbortSignal.timeout(10_000)`; sync nunca lança (try/catch); save de credenciais é raro (assessoria configura uma vez).
- **`revalidateTag` dentro de cache:** a persistência de status no render usa SQL cru (sem hooks) — mesma restrição documentada do snapshot (Next 15.4 lança `revalidateTag` dentro de `unstable_cache`); a rota/hook revalidam fora do cache (permitido).
- **e2e serial com hook novo:** todo seed de credenciais via REST dispara o sync (stub determinístico, ok/fail por estado); resets incluem `instagramSyncStatus: null`; orçamento de polling do serial mantido.
- **Formato do body de erro da Graph API:** documentado (`{ error: { message, type } }`); parse defensivo — body inesperado → mensagem genérica por status (nunca quebra o fluxo).
- **Painel com estado stale após save:** o sync awaited no hook resolve (status escrito antes do reload); botão cobre o resto; "há X min" é honesto (última boa real).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: painel de status na global (sincronizado/falha com motivo+não configurado), botão "Tentar sincronizar de novo", home pública inalterada e fail-closed, runbook curto com validação/geração de token, token nunca fora do admin
- [ ] Invariantes AGENTS/engineering-standards: migration não destrutiva; sem consent novo; identificadores em inglês; copy/admin em pt; access admin-only preservado; rota com auth de sessão + CSRF same-origin; tag `social-feed` já cobre revalidação; SQL cru fora de cache
- [ ] Testes de domínio previstos: unit (mapeamentos de erro, `InstagramApiError`, signal, builders, gate do hook) + e2e (painel ok/falha token/falha 500, status persistido via REST, serial determinístico)

View file

@ -0,0 +1,41 @@
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import { INSTAGRAM_SYNC_TIMEOUT_MS, syncInstagramFeed } from '@/utilities/socialFeed/instagramSync'
import configPromise from '@payload-config'
import { revalidateTag } from 'next/cache'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
/**
* "Tentar sincronizar de novo" — the admin status panel's retry button
* (`InstagramSyncStatusPanel`). Runs one Instagram sync from the persisted
* global settings, writes the outcome status, busts the `social-feed` cache
* so the public board reflects the new snapshot, and returns the status the
* panel renders immediately.
*
* Auth: admin session (the `payload-token` cookie via `payload.auth`, `users`
* collection only — same gate as the global's `read`) plus the same-origin
* CSRF guard every cookie-authenticated JSON route uses. The token and the
* Graph API are never reachable without an admin session.
*/
export async function POST(request: Request): Promise<NextResponse> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
}
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: request.headers })
if (!user || user.collection !== 'users') {
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const outcome = await syncInstagramFeed(payload, {
signal: AbortSignal.timeout(INSTAGRAM_SYNC_TIMEOUT_MS),
})
revalidateTag(REVALIDATE_SOCIAL_FEED_TAG)
return NextResponse.json({ ok: outcome.ok, status: outcome.status })
} catch {
return NextResponse.json({ ok: false, error: 'Falha interna ao sincronizar' }, { status: 500 })
}
}

View file

@ -34,8 +34,8 @@ 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 { InstagramSyncStatusPanel as InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da } from '../../../components/admin/InstagramSyncStatusPanel'
import { InstagramPostExclusionPicker as InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0 } from '../../../components/admin/InstagramPostExclusionPicker' import { InstagramPostExclusionPicker as InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0 } from '../../../components/admin/InstagramPostExclusionPicker'
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
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'
@ -76,8 +76,8 @@ 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/InstagramSyncStatusPanel#InstagramSyncStatusPanel": InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da,
"./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0, "./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0,
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
"@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
} }

View file

@ -67,8 +67,8 @@ export const InstagramPostExclusionPicker: FieldClientComponent = () => {
if (posts.length === 0) { if (posts.length === 0) {
return ( return (
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500">
Nenhum post recente ainda. Com o token e o ID do usuário configurados, os posts aparecem Nenhum post sincronizado. Após uma sincronização bem-sucedida, os últimos posts aparecem
aqui após a primeira sincronização (a cada 5 minutos). aqui para marcar os que não devem ir para a home.
</p> </p>
) )
} }

View file

@ -0,0 +1,186 @@
'use client'
import { useAllFormFields } from '@payloadcms/ui'
import type { FieldClientComponent } from 'payload'
import { useEffect, useState } from 'react'
type InstagramSyncStatusValue = {
lastSyncAt?: string
postCount?: number
error?: string
errorAt?: string
}
type SyncResponse = {
ok: boolean
status?: InstagramSyncStatusValue
error?: string
}
const isSyncStatus = (value: unknown): value is InstagramSyncStatusValue =>
typeof value === 'object' && value !== null
const relativeTimeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' })
const formatSyncTime = (iso?: string): string => {
if (!iso) return ''
const time = new Date(iso).getTime()
if (Number.isNaN(time)) return ''
const seconds = Math.floor((time - Date.now()) / 1000)
if (seconds > -60) return 'agora'
const absSeconds = -seconds
if (absSeconds < 3_600) {
return relativeTimeFormatter.format(-Math.floor(absSeconds / 60), 'minute')
}
if (absSeconds < 86_400) {
return relativeTimeFormatter.format(-Math.floor(absSeconds / 3_600), 'hour')
}
return relativeTimeFormatter.format(-Math.floor(absSeconds / 86_400), 'day')
}
const postCountLabel = (count?: number): string =>
count === undefined ? '' : `${count} ${count === 1 ? 'post' : 'posts'}`
/**
* Sync-status panel of the Instagram feed (S11) — the S3 fail-closed silence
* made the assessoria blind: a wrong token left no trace beyond an empty
* picker. This panel shows, in the same global where the credentials live,
* the state of the last sync — "Sincronizado · há X min · N posts",
* "Falha na última sincronização" with the product-language reason, or
* "Instagram ainda não configurado" — with a "Tentar sincronizar de novo"
* button that runs `POST /api/social-feed/sync` (admin session) and renders
* the outcome. The status itself is persisted server-side by the render
* path, the global's `afterChange` and the sync route; this component only
* reads it from the form and keeps the in-flight result locally.
*/
export const InstagramSyncStatusPanel: FieldClientComponent = () => {
const [fields] = useAllFormFields()
const [outcome, setOutcome] = useState<SyncResponse | null>(null)
const [pending, setPending] = useState(false)
const [requestError, setRequestError] = useState<string | null>(null)
const enabled = fields.enabled?.value !== false && fields.instagramEnabled?.value !== false
const accessToken = fields.instagramAccessToken?.value
const userId = fields.instagramUserId?.value
const stored = fields.instagramSyncStatus?.value
const persisted = isSyncStatus(stored) ? stored : {}
// The panel shows the SERVER-persisted state; the transient retry result in
// `outcome` must not outlive an edit of the credentials it was produced with
// (otherwise it keeps showing the failure of the old token pre-save).
const credentialKey = typeof accessToken === 'string' ? accessToken : ''
const userIdKey = typeof userId === 'string' ? userId : ''
useEffect(() => {
setOutcome(null)
setRequestError(null)
}, [credentialKey, userIdKey])
const configured =
typeof accessToken === 'string' &&
accessToken !== '' &&
typeof userId === 'string' &&
userId !== ''
const status = outcome?.status && isSyncStatus(outcome.status) ? outcome.status : persisted
const failureMessage = status.error ?? null
const lastSyncAt = status.lastSyncAt
const postCount = status.postCount
const retrySync = async () => {
setPending(true)
setRequestError(null)
try {
const response = await fetch('/api/social-feed/sync', { method: 'POST' })
const body = (await response.json()) as SyncResponse
if (response.status === 401) {
setRequestError(
'Sessão do admin expirada. Salve a página e recarregue o admin para reautenticar.',
)
return
}
if (!response.ok || body.error) {
throw new Error(body.error ?? 'Falha na sincronização')
}
setOutcome(body)
} catch {
setRequestError('Não foi possível sincronizar agora. Tente novamente em instantes.')
} finally {
setPending(false)
}
}
const retryButton = (label: string = 'Tentar sincronizar de novo') => (
<button
type="button"
onClick={retrySync}
disabled={pending}
aria-busy={pending}
className="mt-3 rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{pending ? 'Sincronizando…' : label}
</button>
)
if (!enabled) {
return (
<div className="rounded-lg border border-zinc-300 bg-white p-4">
<p className="text-sm font-bold text-zinc-800">Instagram desativado</p>
<p className="mt-1 max-w-2xl text-sm text-zinc-500">
Os posts do Instagram não aparecem no board enquanto o feed estiver desativado.
</p>
</div>
)
}
if (!configured) {
return (
<div className="rounded-lg border border-zinc-300 bg-white p-4">
<p className="text-sm font-bold text-zinc-800">Instagram ainda não configurado</p>
<p className="mt-1 max-w-2xl text-sm text-zinc-500">
Informe o token de acesso e o ID da conta para publicar posts do Instagram na home. Sem
configuração, o board funciona com artigos e YouTube.
</p>
</div>
)
}
if (failureMessage) {
return (
<div className="rounded-lg border border-red-300 bg-red-50 p-4" aria-live="polite">
<p className="text-sm font-bold text-red-800">Falha na última sincronização</p>
<p className="mt-1 max-w-2xl text-sm text-red-700">{failureMessage}</p>
{requestError ? <p className="mt-2 text-sm text-red-700">{requestError}</p> : null}
{retryButton()}
</div>
)
}
if (lastSyncAt) {
return (
<div className="rounded-lg border border-zinc-300 bg-white p-4">
<p className="text-sm font-bold text-zinc-800">
Sincronizado · {formatSyncTime(lastSyncAt)}
{postCount !== undefined ? ` · ${postCountLabel(postCount)}` : ''}
</p>
<p className="mt-1 max-w-2xl text-sm text-zinc-500">
Próxima atualização automática em ~5 min.{' '}
{postCount !== undefined
? `Os ${postCountLabel(postCount)} mais recentes estão no board "Acompanhe de perto" da home.`
: 'Os posts mais recentes estão no board "Acompanhe de perto" da home.'}
</p>
</div>
)
}
return (
<div className="rounded-lg border border-zinc-300 bg-white p-4" aria-live="polite">
<p className="text-sm font-bold text-zinc-800">Aguardando a primeira sincronização</p>
<p className="mt-1 max-w-2xl text-sm text-zinc-500">
Clique em &quot;Tentar sincronizar&quot; para buscar os posts do Instagram agora, ou aguarde
a próxima atualização automática.
</p>
{requestError ? <p className="mt-2 text-sm text-red-700">{requestError}</p> : null}
{retryButton('Tentar sincronizar')}
</div>
)
}

View file

@ -1,7 +1,7 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess' import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest' import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
import { revalidateTag } from 'next/cache' import { revalidateTag } from 'next/cache'
import type { GlobalConfig } from 'payload' import type { GlobalAfterChangeHook, GlobalConfig } from 'payload'
const slug = 'social-feed-settings' const slug = 'social-feed-settings'
@ -9,6 +9,35 @@ const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/
const revalidateFeed = async () => revalidateTag(REVALIDATE_SOCIAL_FEED_TAG) const revalidateFeed = async () => revalidateTag(REVALIDATE_SOCIAL_FEED_TAG)
/**
* Re-syncs Instagram when a save changes the IG credentials (the primary
* flow: the assessoria fills token + ID, saves, and the reload of the global
* already shows the sync state WITH the new credentials — the status panel
* would otherwise keep showing the last failure of the old token). Awaited
* with a bounded deadline so a hanging Graph API never stalls the save; any
* failure is swallowed — the sync's own status IS the result, and a broken
* sync must never break the save.
*
* The sync modules are imported lazily: `payload.config.ts` must not pull
* the feed module graph (`unstable_cache` at load time) — the config is the
* one module every int spec imports.
*/
const syncInstagramAfterChange: GlobalAfterChangeHook = async ({ doc, previousDoc, req }) => {
const { instagramCredentialsChanged, syncInstagramFeed, INSTAGRAM_SYNC_TIMEOUT_MS } =
await import('@/utilities/socialFeed/instagramSync')
if (!instagramCredentialsChanged(doc, previousDoc)) return
try {
// `req` lets the sync write inside this very transaction (same row lock
// held by the save) instead of blocking a second pool connection.
await syncInstagramFeed(req.payload, {
signal: AbortSignal.timeout(INSTAGRAM_SYNC_TIMEOUT_MS),
req,
})
} catch {
// the status panel reflects the failure; the save itself stays green
}
}
/** /**
* Configuration of the campaign home content board's external feeds (plano-site * Configuration of the campaign home content board's external feeds (plano-site
* §4.2): YouTube now, Instagram in S3. The board's read path is the public home * §4.2): YouTube now, Instagram in S3. The board's read path is the public home
@ -29,7 +58,7 @@ export const SocialFeedSettings: GlobalConfig = {
update: payloadAdminOnly, update: payloadAdminOnly,
}, },
hooks: { hooks: {
afterChange: [revalidateFeed], afterChange: [revalidateFeed, syncInstagramAfterChange],
}, },
fields: [ fields: [
{ {
@ -88,6 +117,16 @@ export const SocialFeedSettings: GlobalConfig = {
description: 'Desliga só os cards de post do Instagram.', description: 'Desliga só os cards de post do Instagram.',
}, },
}, },
{
name: 'instagramSyncStatusPanel',
type: 'ui',
label: 'Instagram — estado da sincronização',
admin: {
components: {
Field: './components/admin/InstagramSyncStatusPanel#InstagramSyncStatusPanel',
},
},
},
{ {
name: 'instagramAccessToken', name: 'instagramAccessToken',
type: 'text', type: 'text',
@ -185,5 +224,12 @@ export const SocialFeedSettings: GlobalConfig = {
hidden: true, hidden: true,
}, },
}, },
{
name: 'instagramSyncStatus',
type: 'json',
admin: {
hidden: true,
},
},
], ],
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
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_sync_status" jsonb;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "social_feed_settings" DROP COLUMN "instagram_sync_status";`)
}

View file

@ -58,6 +58,7 @@ import * as migration_20260818_111748_add_social_feed_settings from './20260818_
import * as migration_20260818_192948_add_instagram_social_feed_fields from './20260818_192948_add_instagram_social_feed_fields'; import * as migration_20260818_192948_add_instagram_social_feed_fields from './20260818_192948_add_instagram_social_feed_fields';
import * as migration_20260819_192605_add_campaign_newsletter_capture from './20260819_192605_add_campaign_newsletter_capture'; import * as migration_20260819_192605_add_campaign_newsletter_capture from './20260819_192605_add_campaign_newsletter_capture';
import * as migration_20260819_233503_add_campaign_demand_responsibles from './20260819_233503_add_campaign_demand_responsibles'; import * as migration_20260819_233503_add_campaign_demand_responsibles from './20260819_233503_add_campaign_demand_responsibles';
import * as migration_20260820_003426_add_instagram_sync_status from './20260820_003426_add_instagram_sync_status';
export const migrations = [ export const migrations = [
{ {
@ -351,7 +352,7 @@ export const migrations = [
name: '20260818_192948_add_instagram_social_feed_fields', name: '20260818_192948_add_instagram_social_feed_fields',
}, },
{ {
up: migration_20260819_192605_add_campaign_newsletter_capture.up, up: migration_20260819_192605_add_campaign_newsletter_capture.up,
down: migration_20260819_192605_add_campaign_newsletter_capture.down, down: migration_20260819_192605_add_campaign_newsletter_capture.down,
name: '20260819_192605_add_campaign_newsletter_capture' name: '20260819_192605_add_campaign_newsletter_capture'
}, },
@ -360,4 +361,9 @@ export const migrations = [
down: migration_20260819_233503_add_campaign_demand_responsibles.down, down: migration_20260819_233503_add_campaign_demand_responsibles.down,
name: '20260819_233503_add_campaign_demand_responsibles' name: '20260819_233503_add_campaign_demand_responsibles'
}, },
{
up: migration_20260820_003426_add_instagram_sync_status.up,
down: migration_20260820_003426_add_instagram_sync_status.down,
name: '20260820_003426_add_instagram_sync_status'
},
]; ];

View file

@ -2450,6 +2450,15 @@ export interface SocialFeedSetting {
| number | number
| boolean | boolean
| null; | null;
instagramSyncStatus?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }
@ -2562,6 +2571,7 @@ export interface SocialFeedSettingsSelect<T extends boolean = true> {
}; };
youtubeFeedSnapshot?: T; youtubeFeedSnapshot?: T;
instagramFeedSnapshot?: T; instagramFeedSnapshot?: T;
instagramSyncStatus?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;

View file

@ -33,6 +33,8 @@ export type LoadInstagramFeedArgs = {
maxResults: number maxResults: number
fetchImpl?: (input: string, init?: RequestInit) => Promise<Response> fetchImpl?: (input: string, init?: RequestInit) => Promise<Response>
baseUrl?: string baseUrl?: string
/** Deadline for the Graph API calls (hook-triggered sync must not hang a save). */
signal?: AbortSignal
} }
export type LoadInstagramFeedResult = InstagramFeedResult & { export type LoadInstagramFeedResult = InstagramFeedResult & {
@ -40,6 +42,57 @@ export type LoadInstagramFeedResult = InstagramFeedResult & {
refreshedAccessToken?: string refreshedAccessToken?: string
} }
/**
* API-level failure of the Instagram Graph API: carries the HTTP status and
* the parsed `{ error: { message, type } }` body when present, so
* `describeInstagramError` can turn it into product language (invalid token
* vs wrong user id vs API down) for the admin sync-status panel.
*/
export class InstagramApiError extends Error {
readonly status: number | undefined
readonly apiMessage: string | null
readonly apiType: string | null
constructor(
status: number | undefined,
apiMessage: string | null,
apiType: string | null,
message: string,
) {
super(message)
this.name = 'InstagramApiError'
this.status = status
this.apiMessage = apiMessage
this.apiType = apiType
}
}
const parseApiErrorBody = async (
response: Response,
): Promise<{ message: string | null; type: string | null }> => {
try {
const body = (await response.json()) as { error?: { message?: unknown; type?: unknown } } | null
const error = body?.error
if (typeof error !== 'object' || error === null) return { message: null, type: null }
return {
message: typeof error.message === 'string' && error.message ? error.message : null,
type: typeof error.type === 'string' && error.type ? error.type : null,
}
} catch {
return { message: null, type: null }
}
}
const apiErrorFrom = async (response: Response): Promise<InstagramApiError> => {
const { message, type } = await parseApiErrorBody(response)
return new InstagramApiError(
response.status,
message,
type,
`Instagram API falhou com status ${response.status}`,
)
}
const MEDIA_FIELDS = [ const MEDIA_FIELDS = [
'id', 'id',
'caption', 'caption',
@ -156,12 +209,13 @@ export const loadInstagramFeed = async ({
maxResults, maxResults,
fetchImpl = fetch, fetchImpl = fetch,
baseUrl = INSTAGRAM_API_BASE_URL, baseUrl = INSTAGRAM_API_BASE_URL,
signal,
}: LoadInstagramFeedArgs): Promise<LoadInstagramFeedResult> => { }: LoadInstagramFeedArgs): Promise<LoadInstagramFeedResult> => {
const attempt = async (token: string): Promise<InstagramFeedResult> => { const attempt = async (token: string): Promise<InstagramFeedResult> => {
const userParams = new URLSearchParams({ fields: 'username', access_token: token }) const userParams = new URLSearchParams({ fields: 'username', access_token: token })
const userResponse = await fetchImpl(`${baseUrl}/${userId}?${userParams}`) const userResponse = await fetchImpl(`${baseUrl}/${userId}?${userParams}`, { signal })
if (!userResponse.ok) { if (!userResponse.ok) {
throw new Error(`Instagram user falhou com status ${userResponse.status}`) throw await apiErrorFrom(userResponse)
} }
const userJson = (await jsonFrom(userResponse)) as { username?: unknown } const userJson = (await jsonFrom(userResponse)) as { username?: unknown }
const username = typeof userJson.username === 'string' ? userJson.username : null const username = typeof userJson.username === 'string' ? userJson.username : null
@ -171,9 +225,9 @@ export const loadInstagramFeed = async ({
limit: String(Math.min(Math.max(maxResults, 1), INSTAGRAM_MAX_RESULTS_CAP)), limit: String(Math.min(Math.max(maxResults, 1), INSTAGRAM_MAX_RESULTS_CAP)),
access_token: token, access_token: token,
}) })
const mediaResponse = await fetchImpl(`${baseUrl}/${userId}/media?${mediaParams}`) const mediaResponse = await fetchImpl(`${baseUrl}/${userId}/media?${mediaParams}`, { signal })
if (!mediaResponse.ok) { if (!mediaResponse.ok) {
throw new Error(`Instagram media falhou com status ${mediaResponse.status}`) throw await apiErrorFrom(mediaResponse)
} }
return { username, posts: parseInstagramMediaResponse(await jsonFrom(mediaResponse)) } return { username, posts: parseInstagramMediaResponse(await jsonFrom(mediaResponse)) }
@ -186,9 +240,11 @@ export const loadInstagramFeed = async ({
grant_type: 'ig_refresh_token', grant_type: 'ig_refresh_token',
access_token: accessToken, access_token: accessToken,
}) })
const refreshResponse = await fetchImpl(`${baseUrl}/refresh_access_token?${refreshParams}`) const refreshResponse = await fetchImpl(`${baseUrl}/refresh_access_token?${refreshParams}`, {
signal,
})
if (!refreshResponse.ok) { if (!refreshResponse.ok) {
throw new Error(`Instagram token refresh falhou com status ${refreshResponse.status}`) throw await apiErrorFrom(refreshResponse)
} }
const refreshJson = (await jsonFrom(refreshResponse)) as { access_token?: unknown } const refreshJson = (await jsonFrom(refreshResponse)) as { access_token?: unknown }
if (typeof refreshJson.access_token !== 'string' || !refreshJson.access_token) { if (typeof refreshJson.access_token !== 'string' || !refreshJson.access_token) {
@ -210,33 +266,153 @@ const excludedInstagramIds = (settings: SocialFeedSetting): string[] =>
const isInstagramSnapshot = (value: unknown): value is { username?: unknown; posts?: unknown } => const isInstagramSnapshot = (value: unknown): value is { username?: unknown; posts?: unknown } =>
typeof value === 'object' && value !== null typeof value === 'object' && value !== null
/**
* Operational state of the Instagram sync, persisted in the global's
* `instagram_sync_status` column and shown by the admin status panel:
* `lastSyncAt`/`postCount` are the last SUCCESSFUL sync; `error`/`errorAt`
* the last failure. A failure overwrites the success fields on purpose — the
* panel shows either state, never both (draft cenas 1/2).
*/
export type InstagramSyncStatus = {
lastSyncAt?: string
postCount?: number
error?: string
errorAt?: string
}
export const successInstagramSyncStatus = (postCount: number): InstagramSyncStatus => ({
lastSyncAt: new Date().toISOString(),
postCount,
})
export const failedInstagramSyncStatus = (error: string): InstagramSyncStatus => ({
error,
errorAt: new Date().toISOString(),
})
const isTokenError = (error: InstagramApiError): boolean =>
error.apiType === 'OAuthException' ||
/(expired|invalid|revoked|token|session)/i.test(error.apiMessage ?? '')
/**
* True when the API message points at a bad user id rather than a bad token.
* Requires "user" AND "id" together (or an explicit "object with id ... does
* not exist") — a single "user" alone is NOT enough, because real token
* rejections read "the user must be logged in" / "the user has changed their
* password", and those must stay on the token correction, not the ID one.
*/
const isInvalidUserIdMessage = (message: string): boolean =>
/(?=.*\buser\b)(?=.*\bid\b)/i.test(message) ||
/\bid\b[\s\S]*\b(?:does not exist|cannot find)\b/i.test(message)
const isAbortError = (cause: unknown): boolean =>
cause instanceof Error && cause.name === 'AbortError'
/**
* Turns a sync failure into product language for the admin status panel: the
* assessoria must know WHY the board has no Instagram cards and what to do
* (the S3 fail-closed silence is the bug this exists to fix). Token errors
* carry the correction — regenerate via Instagram Login, never Facebook
* Login (the Graph API refresh endpoint rejects FB-issued page tokens).
*/
export const describeInstagramError = (cause: unknown): string => {
if (isAbortError(cause)) {
return 'A sincronização demorou demais para responder (a API do Instagram não respondeu a tempo). Tente novamente.'
}
if (cause instanceof InstagramApiError) {
if (cause.status === 400 && isInvalidUserIdMessage(cause.apiMessage ?? '')) {
return 'O ID do usuário não foi reconhecido pela Graph API. Confira se é o ID numérico da conta Business/Creator.'
}
if (typeof cause.status === 'number' && cause.status >= 400 && cause.status < 500) {
if (isTokenError(cause)) {
return 'O token do Instagram foi recusado pela Graph API — está inválido/expirado ou foi emitido via Facebook Login (a Graph API só aceita tokens gerados pelo Instagram Login). Gere um novo token de longa duração pelo Instagram Login e atualize o campo acima.'
}
return 'A Graph API recusou a solicitação. Confira o token e o ID do usuário configurados.'
}
return 'A Graph API está indisponível no momento. Tente novamente em alguns minutos.'
}
if (cause instanceof TypeError) {
return 'Não foi possível falar com a API do Instagram (rede indisponível).'
}
return 'Erro inesperado ao sincronizar o Instagram.'
}
/**
* Database seam for the raw-SQL persists. The default is the pool
* (`payload.db.drizzle`); the hook-triggered sync passes the transaction-bound
* database (`getPostgresTransactionDatabase`, S11) so the write lands inside
* the global save's transaction — writing the same row from a second pool
* connection while the save holds its row lock deadlocks on that lock.
*/
export type InstagramPersistDatabase = {
execute: (query: ReturnType<typeof sql>) => Promise<unknown>
}
const countAffected = (result: unknown): number | null | undefined =>
(result as { rowCount?: number | null } | undefined)?.rowCount
/**
* Persists the sync status the same way as the snapshot — deliberately raw
* SQL 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. Same
* precondition as the snapshot: at most one `social_feed_settings` row
* exists; the INSERT only fires when none does.
*/
export const persistInstagramSyncStatus = async (
payload: Payload,
status: InstagramSyncStatus,
database: InstagramPersistDatabase | null = null,
): Promise<void> => {
const db = database ?? payload.db.drizzle
const json = JSON.stringify(status)
const updated = await db.execute(sql`
UPDATE "social_feed_settings"
SET "instagram_sync_status" = ${json}::jsonb, "updated_at" = now()
WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1)
`)
if (!countAffected(updated)) {
await db.execute(sql`
INSERT INTO "social_feed_settings" ("instagram_sync_status", "created_at", "updated_at")
VALUES (${json}::jsonb, now(), now())
`)
}
}
/** True when the Instagram feed is armed to call the Graph API. */
export const isInstagramFeedConfigured = (settings: SocialFeedSetting): boolean =>
settings.enabled !== false &&
settings.instagramEnabled !== false &&
Boolean(settings.instagramAccessToken) &&
Boolean(settings.instagramUserId)
/** /**
* Persists the raw feed as the global's snapshot column. Deliberately raw SQL * Persists the raw feed as the global's snapshot column. Deliberately raw SQL
* (`payload.db.drizzle`) instead of `payload.updateGlobal`: this runs INSIDE * instead of `payload.updateGlobal`: this runs INSIDE `unstable_cache` during
* `unstable_cache` during a page render, and Next 15.4 throws when * a page render, and Next 15.4 throws when `revalidateTag` (the global's
* `revalidateTag` (the global's `afterChange` hook) fires inside a cached * `afterChange` hook) fires inside a cached function. Raw SQL bypasses hooks;
* function. Raw SQL bypasses hooks; the row is created on first write when the * the row is created on first write when the admin has not saved the global
* admin has not saved the global yet (a serial id is assigned, so no id * yet (a serial id is assigned, so no id assumption is made).
* assumption is made).
* *
* Precondition: at most one `social_feed_settings` row exists. The UPDATE * Precondition: at most one `social_feed_settings` row exists. The UPDATE
* targets the single row and the INSERT only fires when none exists; a * 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 * 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. * (the row is created by the first admin save or feed render) — accepted.
*/ */
const persistInstagramSnapshot = async ( export const persistInstagramSnapshot = async (
payload: Payload, payload: Payload,
snapshot: InstagramFeedResult, snapshot: InstagramFeedResult,
database: InstagramPersistDatabase | null = null,
): Promise<void> => { ): Promise<void> => {
const database = payload.db.drizzle const db = database ?? payload.db.drizzle
const json = JSON.stringify(snapshot) const json = JSON.stringify(snapshot)
const updated = await database.execute(sql` const updated = await db.execute(sql`
UPDATE "social_feed_settings" UPDATE "social_feed_settings"
SET "instagram_feed_snapshot" = ${json}::jsonb, "updated_at" = now() SET "instagram_feed_snapshot" = ${json}::jsonb, "updated_at" = now()
WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1) WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1)
`) `)
if (!updated?.rowCount) { if (!countAffected(updated)) {
await database.execute(sql` await db.execute(sql`
INSERT INTO "social_feed_settings" ("instagram_feed_snapshot", "created_at", "updated_at") INSERT INTO "social_feed_settings" ("instagram_feed_snapshot", "created_at", "updated_at")
VALUES (${json}::jsonb, now(), now()) VALUES (${json}::jsonb, now(), now())
`) `)
@ -244,8 +420,13 @@ const persistInstagramSnapshot = async (
} }
/** Persists a refreshed token the same way as the snapshot (raw SQL, no hooks). */ /** Persists a refreshed token the same way as the snapshot (raw SQL, no hooks). */
const persistInstagramAccessToken = async (payload: Payload, token: string): Promise<void> => { export const persistInstagramAccessToken = async (
await payload.db.drizzle.execute(sql` payload: Payload,
token: string,
database: InstagramPersistDatabase | null = null,
): Promise<void> => {
const db = database ?? payload.db.drizzle
await db.execute(sql`
UPDATE "social_feed_settings" UPDATE "social_feed_settings"
SET "instagram_access_token" = ${token}, "updated_at" = now() SET "instagram_access_token" = ${token}, "updated_at" = now()
WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1) WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1)
@ -256,9 +437,11 @@ const persistInstagramAccessToken = async (payload: Payload, token: string): Pro
* Cached Instagram feed for the campaign home content board: `null` when the * Cached Instagram feed for the campaign home content board: `null` when the
* feed is off (kill switch, platform toggle or missing credentials), otherwise * feed is off (kill switch, platform toggle or missing credentials), otherwise
* the latest eligible posts plus the profile username. A successful fetch * the latest eligible posts plus the profile username. A successful fetch
* persists the RAW list (pre-exclusion) as the global snapshot; a failed fetch * persists the RAW list (pre-exclusion) as the global snapshot AND the sync
* falls back to that snapshot with the current exclusions applied — so a new * status (so the admin panel shows "Sincronizado · há X min · N posts"); a
* exclusion works even while the API is down, and the page never breaks. * failed fetch persists the failure status with the product-language reason
* and falls back to the 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 * Busted by the global's `afterChange` (tag `social-feed`); the per-entry
* `revalidate` keeps new uploads appearing without an admin edit. * `revalidate` keeps new uploads appearing without an admin edit.
*/ */
@ -270,12 +453,7 @@ export const getInstagramFeed = unstable_cache(
depth: 0, depth: 0,
}) })
if ( if (!isInstagramFeedConfigured(settings)) {
settings.enabled === false ||
settings.instagramEnabled === false ||
!settings.instagramAccessToken ||
!settings.instagramUserId
) {
return null return null
} }
@ -284,19 +462,24 @@ export const getInstagramFeed = unstable_cache(
try { try {
const result = await loadInstagramFeed({ const result = await loadInstagramFeed({
accessToken: settings.instagramAccessToken, accessToken: settings.instagramAccessToken as string,
userId: settings.instagramUserId, userId: settings.instagramUserId as string,
maxResults: Math.min(maxItems + 10, INSTAGRAM_MAX_RESULTS_CAP), maxResults: Math.min(maxItems + 10, INSTAGRAM_MAX_RESULTS_CAP),
}) })
await persistInstagramSnapshot(payload, { username: result.username, posts: result.posts }) await persistInstagramSnapshot(payload, { username: result.username, posts: result.posts })
if (result.refreshedAccessToken) { if (result.refreshedAccessToken) {
await persistInstagramAccessToken(payload, result.refreshedAccessToken) await persistInstagramAccessToken(payload, result.refreshedAccessToken)
} }
await persistInstagramSyncStatus(payload, successInstagramSyncStatus(result.posts.length))
return { return {
username: result.username, username: result.username,
posts: eligibleInstagramPosts(result.posts, excludedIds, maxItems), posts: eligibleInstagramPosts(result.posts, excludedIds, maxItems),
} }
} catch { } catch (cause) {
await persistInstagramSyncStatus(
payload,
failedInstagramSyncStatus(describeInstagramError(cause)),
)
const snapshot = settings.instagramFeedSnapshot as unknown const snapshot = settings.instagramFeedSnapshot as unknown
if (!isInstagramSnapshot(snapshot) || !Array.isArray(snapshot.posts)) { if (!isInstagramSnapshot(snapshot) || !Array.isArray(snapshot.posts)) {
return { username: null, posts: [] } return { username: null, posts: [] }

View file

@ -0,0 +1,117 @@
import 'server-only'
import type { SocialFeedSetting } from '@/payload-types'
import { getPostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks'
import {
describeInstagramError,
failedInstagramSyncStatus,
INSTAGRAM_MAX_RESULTS_CAP,
isInstagramFeedConfigured,
loadInstagramFeed,
persistInstagramAccessToken,
persistInstagramSnapshot,
persistInstagramSyncStatus,
successInstagramSyncStatus,
type InstagramPersistDatabase,
type InstagramSyncStatus,
} from '@/utilities/socialFeed/instagramFeed'
import type { Payload, PayloadRequest } from 'payload'
/** Deadline for a sync triggered by the admin (hook or button) — a hanging
* Graph API must never stall a global save. */
export const INSTAGRAM_SYNC_TIMEOUT_MS = 10_000
export type InstagramSyncOutcome = {
ok: boolean
status: InstagramSyncStatus
}
/**
* True when a save of the global must re-sync Instagram: the credentials
* changed (the primary flow — "fill token + ID, save, see the state") or the
* feed was re-enabled. Exclusion-only saves skip the sync on purpose: they
* change no credential and would burn an API call for nothing.
*/
export const instagramCredentialsChanged = (
doc: SocialFeedSetting,
previousDoc: SocialFeedSetting | null | undefined,
): boolean => {
if (!previousDoc) return true
const credentialsChanged =
(doc.instagramAccessToken ?? '') !== (previousDoc.instagramAccessToken ?? '') ||
(doc.instagramUserId ?? '') !== (previousDoc.instagramUserId ?? '')
const reenabled = doc.instagramEnabled === true && previousDoc.instagramEnabled !== true
return credentialsChanged || reenabled
}
/**
* The afterChange hook runs INSIDE the global save's transaction (the row lock
* is held until it returns), so a write to the same row from a second pool
* connection would block on that lock. When a `req` with a `transactionID` is
* present (hook path), the persists go through the transaction-bound database
* instead — the status/snapshot land atomically with the save, no deadlock.
* The render path and the sync route have no open transaction and keep the
* pool (`payload.db.drizzle`).
*/
const resolvePersistDatabase = async (
payload: Payload,
req?: PayloadRequest,
): Promise<InstagramPersistDatabase | null> => {
if (!req?.transactionID) return null
try {
return await getPostgresTransactionDatabase(payload, req)
} catch {
// No transaction adapter available (e.g. non-postgres in tests) — pool.
return null
}
}
/**
* Runs one Instagram sync from the persisted settings: fetches the Graph API,
* persists the raw snapshot + a refreshed token when one was minted, and
* writes the outcome status. Never throws — the status IS the result (a
* failure writes the product-language reason the admin panel shows). Shared
* by the global's `afterChange` hook (on credential change) and the
* `POST /api/social-feed/sync` route (the "Tentar sincronizar de novo"
* button) — one implementation, two call sites.
*/
export const syncInstagramFeed = async (
payload: Payload,
options: { signal?: AbortSignal; req?: PayloadRequest } = {},
): Promise<InstagramSyncOutcome> => {
const settings = await payload.findGlobal({
slug: 'social-feed-settings',
depth: 0,
})
if (!isInstagramFeedConfigured(settings)) {
return { ok: false, status: {} }
}
const maxItems = Math.min(Math.max(settings.instagramMaxItems ?? 3, 1), 5)
const database = await resolvePersistDatabase(payload, options.req)
try {
const result = await loadInstagramFeed({
accessToken: settings.instagramAccessToken as string,
userId: settings.instagramUserId as string,
maxResults: Math.min(maxItems + 10, INSTAGRAM_MAX_RESULTS_CAP),
signal: options.signal,
})
await persistInstagramSnapshot(
payload,
{ username: result.username, posts: result.posts },
database,
)
if (result.refreshedAccessToken) {
await persistInstagramAccessToken(payload, result.refreshedAccessToken, database)
}
const status = successInstagramSyncStatus(result.posts.length)
await persistInstagramSyncStatus(payload, status, database)
return { ok: true, status }
} catch (cause) {
const status = failedInstagramSyncStatus(describeInstagramError(cause))
await persistInstagramSyncStatus(payload, status, database)
return { ok: false, status }
}
}

View file

@ -1,5 +1,6 @@
import { login } from '../helpers/login' import { login } from '../helpers/login'
import { cleanupTestUser, seedTestUser, testUser } from '../helpers/seedUser' import { cleanupTestUser, seedTestUser, testUser } from '../helpers/seedUser'
import { instagramStubUrlFor } from '../helpers/socialStub'
import { expect, test } from './fixtures/e2eTest' import { expect, test } from './fixtures/e2eTest'
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000' const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
@ -127,6 +128,140 @@ test.describe('Admin Panel', () => {
excludedItems: [], excludedItems: [],
youtubeFeedSnapshot: null, youtubeFeedSnapshot: null,
instagramFeedSnapshot: null, instagramFeedSnapshot: null,
instagramSyncStatus: null,
},
})
.catch(() => undefined)
}
})
test('shows the Instagram sync status and retries the sync from the panel', async ({
page,
request,
}) => {
// The seed POST fires the global's afterChange hook, whose sync hits the
// stub — put it in a known state first and let the hook persist the
// status so the panel has a real one to render.
const stubUrl = instagramStubUrlFor(baseURL)
const setStub = async (state: 'ok' | 'fail' | 'invalid-token') => {
const response = await request.post(`${stubUrl}/__stub/state`, { data: { state } })
expect(response.ok()).toBeTruthy()
}
await setStub('ok')
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 seed = await request.post(`${baseURL}/api/globals/social-feed-settings`, {
headers,
data: {
enabled: true,
instagramEnabled: true,
instagramAccessToken: 'e2e-ig-token',
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
excludedItems: [],
instagramFeedSnapshot: null,
},
})
expect(seed.ok()).toBeTruthy()
const seedToken = async (accessToken: string) => {
const response = await request.post(`${baseURL}/api/globals/social-feed-settings`, {
headers,
data: {
enabled: true,
instagramEnabled: true,
instagramAccessToken: accessToken,
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
excludedItems: [],
instagramFeedSnapshot: null,
},
})
expect(response.ok()).toBeTruthy()
}
try {
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
await expect(page.getByText(/Sincronizado · .* · 5 posts/)).toBeVisible()
// Credential change + save triggers the hook sync — with the API now
// rejecting the token (OAuthException), the save persists a failure
// status and the reload shows it with the retry button.
await setStub('invalid-token')
await seedToken('e2e-ig-token-2')
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
await expect(page.getByText('Falha na última sincronização')).toBeVisible()
await expect(page.getByText(/token do Instagram foi recusado/)).toBeVisible()
// Retry after fixing: the button runs the sync route → synchronized.
await setStub('ok')
await page.getByRole('button', { name: 'Tentar sincronizar de novo' }).click()
await expect(page.getByText(/Sincronizado · .* · 5 posts/)).toBeVisible()
const read = await request.get(`${baseURL}/api/globals/social-feed-settings`, { headers })
expect(read.ok()).toBeTruthy()
const { instagramSyncStatus } = await read.json()
expect(instagramSyncStatus).toEqual(
expect.objectContaining({
lastSyncAt: expect.any(String),
postCount: 5,
}),
)
expect(instagramSyncStatus.error).toBeUndefined()
// Fail-500 scene: a server-side API failure maps to the generic notice.
await setStub('fail')
await seedToken('e2e-ig-token-3')
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
await expect(page.getByText('Falha na última sincronização')).toBeVisible()
await expect(page.getByText(/indisponível/)).toBeVisible()
// Disabled scene: the master kill switch off renders the disabled box.
await setStub('ok')
await request.post(`${baseURL}/api/globals/social-feed-settings`, {
headers,
data: {
enabled: false,
instagramEnabled: true,
instagramAccessToken: 'e2e-ig-token',
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
excludedItems: [],
instagramFeedSnapshot: null,
},
})
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
await expect(page.getByText('Instagram desativado')).toBeVisible()
// Unconfigured state: no credentials (feed enabled) → the panel says so.
await seedToken('')
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
await expect(page.getByText('Instagram ainda não configurado')).toBeVisible()
} finally {
await setStub('ok').catch(() => undefined)
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,
instagramSyncStatus: null,
}, },
}) })
.catch(() => undefined) .catch(() => undefined)

View file

@ -747,6 +747,7 @@ test.describe('Campaign home content section', () => {
excludedItems: [], excludedItems: [],
youtubeFeedSnapshot: null, youtubeFeedSnapshot: null,
instagramFeedSnapshot: null, instagramFeedSnapshot: null,
instagramSyncStatus: null,
}) })
} }

View file

@ -3,11 +3,14 @@
* board (S3) fetches the feed server-side, so the Next dev/prod server points * 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 * `INSTAGRAM_API_BASE_URL` here (see playwright.config.ts) and this process
* answers `/media`, `/user` and `/refresh_access_token` with a fixed fixture. * answers `/media`, `/user` and `/refresh_access_token` with a fixed fixture.
* `POST /__stub/state` with `{ "state": "ok" | "fail" }` makes the API fail * `POST /__stub/state` with `{ "state": "ok" | "fail" | "invalid-token" }`
* (HTTP 500) so specs can exercise the fail-closed/snapshot paths without any * switches the API: `fail` answers HTTP 500 (network/API-down paths), and
* network. Thumbnails are served locally from `/thumbs/<id>.jpg` — the * `invalid-token` answers 400 with an OAuthException body (the S11 admin
* sandbox never reaches `scontent.cdninstagram.com`, and next/image would 404 * status panel's token-rejected copy) — so specs can exercise the
* (a console error the e2e guard treats as a failure). * 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' import { createServer } from 'node:http'
@ -99,7 +102,7 @@ const server = createServer((req, res) => {
req.on('end', () => { req.on('end', () => {
try { try {
const parsed = JSON.parse(body) const parsed = JSON.parse(body)
if (parsed.state === 'ok' || parsed.state === 'fail') state = parsed.state if (['ok', 'fail', 'invalid-token'].includes(parsed.state)) state = parsed.state
} catch { } catch {
// keep the current state // keep the current state
} }
@ -108,6 +111,19 @@ const server = createServer((req, res) => {
return return
} }
if (state === 'invalid-token') {
// The token-rejected shape the Graph API returns for an invalid/expired
// token or one minted via Facebook Login (S11 status panel copy).
json(res, 400, {
error: {
message: 'Error validating access token: Session has expired',
type: 'OAuthException',
code: 190,
},
})
return
}
if (state === 'fail') { if (state === 'fail') {
res.writeHead(500) res.writeHead(500)
res.end('stub fail') res.end('stub fail')

View file

@ -206,6 +206,10 @@ describe('campaign JSON mutation route convention', () => {
'src/app/(frontend)/api/revalidate/route.ts', 'src/app/(frontend)/api/revalidate/route.ts',
'server-to-server: authenticated by a secret header, deliberately callable cross-origin', 'server-to-server: authenticated by a secret header, deliberately callable cross-origin',
], ],
[
'src/app/(frontend)/api/social-feed/sync/route.ts',
'admin-session authenticated (payload.auth, users collection) + explicit isSameOriginRequest; bodyless action returning the sync status — the campaignJsonMutationRoute envelope is campaign-scoped ({status:error})',
],
[ [
'src/app/(campaign)/campanha/api/ai-chat/route.ts', 'src/app/(campaign)/campanha/api/ai-chat/route.ts',
'streaming AI endpoint (ReadableStream, not JSON) — cookie-authenticated via campaign-token, origin-checked by cookie path', 'streaming AI endpoint (ReadableStream, not JSON) — cookie-authenticated via campaign-token, origin-checked by cookie path',

View file

@ -334,4 +334,77 @@ describe('loadInstagramFeed', () => {
}) })
expect(result.username).toBeNull() expect(result.username).toBeNull()
}) })
it('throws an InstagramApiError carrying the parsed Graph API error body', async () => {
const oauthError = {
error: {
message: 'Error validating access token: Session has expired',
type: 'OAuthException',
code: 190,
},
}
const userCall = loadInstagramFeed({
...args,
fetchImpl: async (input) =>
input.includes('/media') ? fakeResponse({}, false) : fakeResponse(oauthError, false),
})
await expect(userCall).rejects.toMatchObject({
name: 'InstagramApiError',
status: undefined,
apiMessage: 'Error validating access token: Session has expired',
apiType: 'OAuthException',
})
// A refresh rejected with the same OAuth body surfaces the typed error too
// (the FB-page-token case: refresh fails → the panel must say why).
const refreshRejectCall = loadInstagramFeed({
...args,
fetchImpl: async (input) =>
input.includes('/refresh_access_token')
? fakeResponse(oauthError, false)
: fakeResponse({}, false),
})
await expect(refreshRejectCall).rejects.toMatchObject({ apiType: 'OAuthException' })
})
it('survives a non-JSON error body (status kept, api message null)', async () => {
const errorResponse = {
ok: false,
status: 502,
json: async () => {
throw new Error('boom')
},
} as unknown as Response
await expect(
loadInstagramFeed({
...args,
fetchImpl: async () => errorResponse,
}),
).rejects.toMatchObject({
name: 'InstagramApiError',
status: 502,
apiMessage: null,
apiType: null,
})
})
it('forwards the abort signal to every fetch', async () => {
const receivedInits: RequestInit[] = []
const fetchImpl = async (input: string, init?: RequestInit): Promise<Response> => {
receivedInits.push(init ?? {})
if (input.includes('/media')) {
return fakeResponse({ data: [MEDIA_ITEM('p')] })
}
if (input.includes('/refresh_access_token')) {
return fakeResponse({ access_token: 't' })
}
return fakeResponse({ username: 'depjorgesolla' })
}
const controller = new AbortController()
await loadInstagramFeed({ ...args, fetchImpl, signal: controller.signal })
expect(receivedInits).toHaveLength(2)
for (const init of receivedInits) {
expect(init.signal).toBe(controller.signal)
}
})
}) })

View file

@ -0,0 +1,247 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SocialFeedSetting } from '@/payload-types'
import {
describeInstagramError,
failedInstagramSyncStatus,
InstagramApiError,
isInstagramFeedConfigured,
successInstagramSyncStatus,
} from '@/utilities/socialFeed/instagramFeed'
import {
instagramCredentialsChanged,
syncInstagramFeed,
} from '@/utilities/socialFeed/instagramSync'
import type { Payload, PayloadRequest } from 'payload'
const configuredSettings = (overrides: Partial<SocialFeedSetting> = {}): SocialFeedSetting =>
({
id: 1,
enabled: true,
instagramEnabled: true,
instagramAccessToken: 'ig-token',
instagramUserId: '17841400000000000',
instagramMaxItems: 3,
...overrides,
}) as SocialFeedSetting
const fakePayload = (settings: SocialFeedSetting, db: unknown = undefined) =>
({
findGlobal: vi.fn(async () => settings),
db: db ?? { drizzle: { execute: vi.fn(async () => ({ rowCount: 1 })) } },
}) as unknown as Payload
const feedResponse = (json: unknown, ok = true, status = 200) =>
({ ok, status, json: async () => json }) as unknown as Response
afterEach(() => {
vi.unstubAllGlobals()
})
describe('successInstagramSyncStatus / failedInstagramSyncStatus', () => {
it('builds a success status with now and the raw post count', () => {
const status = successInstagramSyncStatus(4)
expect(status.postCount).toBe(4)
expect(status.lastSyncAt).toBeDefined()
expect(new Date(status.lastSyncAt as string).getTime()).toBeGreaterThan(0)
expect(status.error).toBeUndefined()
})
it('builds a failure status with the reason and the failure time', () => {
const status = failedInstagramSyncStatus('motivo')
expect(status.error).toBe('motivo')
expect(status.errorAt).toBeDefined()
expect(status.lastSyncAt).toBeUndefined()
})
})
describe('isInstagramFeedConfigured', () => {
it('requires enablement and both credentials', () => {
expect(isInstagramFeedConfigured(configuredSettings())).toBe(true)
expect(isInstagramFeedConfigured(configuredSettings({ instagramAccessToken: '' }))).toBe(false)
expect(isInstagramFeedConfigured(configuredSettings({ instagramUserId: '' }))).toBe(false)
expect(isInstagramFeedConfigured(configuredSettings({ enabled: false }))).toBe(false)
expect(isInstagramFeedConfigured(configuredSettings({ instagramEnabled: false }))).toBe(false)
})
})
describe('instagramCredentialsChanged', () => {
it('syncs on first save, credential changes and re-enable, not on exclusion-only saves', () => {
const previous = configuredSettings()
expect(instagramCredentialsChanged(previous, null)).toBe(true)
expect(
instagramCredentialsChanged(
configuredSettings({ instagramAccessToken: 'novo-token' }),
previous,
),
).toBe(true)
expect(
instagramCredentialsChanged(configuredSettings({ instagramUserId: '999' }), previous),
).toBe(true)
expect(
instagramCredentialsChanged(
configuredSettings({ instagramEnabled: true }),
configuredSettings({ instagramEnabled: false }),
),
).toBe(true)
expect(
instagramCredentialsChanged(
configuredSettings({ excludedItems: [{ platform: 'instagram', itemId: 'x' }] }),
previous,
),
).toBe(false)
})
})
describe('describeInstagramError', () => {
const apiError = (status: number, apiMessage: string | null, apiType: string | null) =>
new InstagramApiError(status, apiMessage, apiType, 'fail')
it('maps an OAuth rejection to the token correction (Instagram Login, never Facebook Login)', () => {
const message = describeInstagramError(
apiError(400, 'Error validating access token: Session has expired', 'OAuthException'),
)
expect(message).toContain('token')
expect(message).toContain('Instagram Login')
expect(message).toContain('Facebook Login')
})
it('maps an invalid token by keyword even without an OAuthException type', () => {
expect(
describeInstagramError(apiError(403, 'Invalid OAuth access token', 'GraphMethodException')),
).toContain('Instagram Login')
})
it('maps an unknown user id to the ID correction', () => {
const message = describeInstagramError(apiError(400, 'Invalid user id', 'OAuthException'))
expect(message).toContain('ID do usuário')
expect(message).not.toContain('Instagram Login')
})
it('keeps a token OAuth rejection on the token correction even when the message says "user"', () => {
// "The user must be logged in" contains the word "user" but is a TOKEN
// problem — mapping it to the ID branch would be the very misdiagnosis
// this panel exists to kill.
const message = describeInstagramError(
apiError(400, 'Error validating access token: The user must be logged in', 'OAuthException'),
)
expect(message).toContain('Instagram Login')
expect(message).not.toContain('ID do usuário')
})
it('maps a request timeout (aborted sync) to a timeout reason', () => {
const abort = new Error('This operation was aborted')
abort.name = 'AbortError'
expect(describeInstagramError(abort)).toContain('demorou demais')
})
it('maps server errors and network failures honestly', () => {
expect(describeInstagramError(apiError(500, null, null))).toContain('indisponível')
expect(describeInstagramError(new TypeError('fetch failed'))).toContain('rede')
expect(describeInstagramError(new Error('whatever'))).toContain('inesperado')
})
})
describe('syncInstagramFeed', () => {
const okFeedResponse = () =>
({
ok: true,
json: async () => ({ data: [] }),
}) as unknown as Response
it('persists snapshot and status and reports success', async () => {
const fetchImpl = vi.fn(async () => okFeedResponse())
vi.stubGlobal('fetch', fetchImpl)
const payload = fakePayload(configuredSettings())
const outcome = await syncInstagramFeed(payload)
expect(outcome.ok).toBe(true)
expect(outcome.status.lastSyncAt).toBeDefined()
expect(outcome.status.postCount).toBe(0)
expect(payload.db.drizzle.execute).toHaveBeenCalled()
})
it('reports the product-language failure status when the API rejects the token', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
feedResponse(
{ error: { message: 'Error validating access token', type: 'OAuthException' } },
false,
400,
),
),
)
const payload = fakePayload(configuredSettings())
const outcome = await syncInstagramFeed(payload)
expect(outcome.ok).toBe(false)
expect(outcome.status.error).toContain('Instagram Login')
expect(outcome.status.errorAt).toBeDefined()
})
it('returns an empty status without touching the API when not configured', async () => {
const fetchImpl = vi.fn()
vi.stubGlobal('fetch', fetchImpl)
const payload = fakePayload(configuredSettings({ instagramAccessToken: '' }))
const outcome = await syncInstagramFeed(payload)
expect(outcome.ok).toBe(false)
expect(outcome.status).toEqual({})
expect(fetchImpl).not.toHaveBeenCalled()
})
it('propagates the abort signal to the fetch', async () => {
const fetchImpl = vi.fn(
async (_input: string, _init?: RequestInit): Promise<Response> => okFeedResponse(),
)
vi.stubGlobal('fetch', fetchImpl)
const payload = fakePayload(configuredSettings())
const controller = new AbortController()
await syncInstagramFeed(payload, { signal: controller.signal })
expect(fetchImpl.mock.calls[0][1]).toEqual({ signal: controller.signal })
})
it('writes through the transaction database when a transaction is open (hook path)', async () => {
const poolExecute = vi.fn(async () => ({ rowCount: 1 }))
const txExecute = vi.fn(async () => ({ rowCount: 1 }))
const payload = fakePayload(configuredSettings(), {
name: 'postgres',
sessions: { tx1: { db: { execute: txExecute } } },
drizzle: { execute: poolExecute },
})
const fetchImpl = vi.fn(async () => okFeedResponse())
vi.stubGlobal('fetch', fetchImpl)
const result = await syncInstagramFeed(payload, {
req: { transactionID: 'tx1' } as unknown as PayloadRequest,
})
expect(result.ok).toBe(true)
expect(txExecute).toHaveBeenCalled()
expect(poolExecute).not.toHaveBeenCalled()
})
it('falls back to the pool when the transaction database is unavailable', async () => {
const poolExecute = vi.fn(async () => ({ rowCount: 1 }))
// `db.name` not postgres → getPostgresTransactionDatabase throws → pool.
const payload = fakePayload(configuredSettings(), {
name: 'not-postgres',
drizzle: { execute: poolExecute },
})
const fetchImpl = vi.fn(async () => okFeedResponse())
vi.stubGlobal('fetch', fetchImpl)
const result = await syncInstagramFeed(payload, {
req: { transactionID: 'tx1' } as unknown as PayloadRequest,
})
expect(result.ok).toBe(true)
expect(poolExecute).toHaveBeenCalled()
})
})