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:
parent
dba75ff886
commit
3ce5a69555
22 changed files with 15073 additions and 44 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
1
docs/changelog/2026-08-19-s11.md
Normal file
1
docs/changelog/2026-08-19-s11.md
Normal 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.
|
||||||
71
docs/ops/instagram-feed-token-runbook.md
Normal file
71
docs/ops/instagram-feed-token-runbook.md
Normal 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.
|
||||||
123
docs/plans/feed-instagram-nao-aparece-home-impl.md
Normal file
123
docs/plans/feed-instagram-nao-aparece-home-impl.md
Normal 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)
|
||||||
41
src/app/(frontend)/api/social-feed/sync/route.ts
Normal file
41
src/app/(frontend)/api/social-feed/sync/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
186
src/components/admin/InstagramSyncStatusPanel.tsx
Normal file
186
src/components/admin/InstagramSyncStatusPanel.tsx
Normal 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 "Tentar sincronizar" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13755
src/migrations/20260820_003426_add_instagram_sync_status.json
Normal file
13755
src/migrations/20260820_003426_add_instagram_sync_status.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260820_003426_add_instagram_sync_status.ts
Normal file
11
src/migrations/20260820_003426_add_instagram_sync_status.ts
Normal 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";`)
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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: [] }
|
||||||
|
|
|
||||||
117
src/utilities/socialFeed/instagramSync.ts
Normal file
117
src/utilities/socialFeed/instagramSync.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -747,6 +747,7 @@ test.describe('Campaign home content section', () => {
|
||||||
excludedItems: [],
|
excludedItems: [],
|
||||||
youtubeFeedSnapshot: null,
|
youtubeFeedSnapshot: null,
|
||||||
instagramFeedSnapshot: null,
|
instagramFeedSnapshot: null,
|
||||||
|
instagramSyncStatus: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
247
tests/unit/instagramSync.unit.spec.ts
Normal file
247
tests/unit/instagramSync.unit.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue