From b4484ec604d29f24432321a12b29e8dc04d57a01 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 24 Aug 2026 17:22:41 -0300 Subject: [PATCH] =?UTF-8?q?fix(social-feed):=20S11-POLISH=20rota=20sync=20?= =?UTF-8?q?400=20em=20n=C3=A3o-configurado=20e=20dedup=20InstagramPersistD?= =?UTF-8?q?atabase=20(Closes=20#761)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rota POST /api/social-feed/sync devolve 400 { ok:false, error } quando IG não configurado (antes 200 status:{}), alinhando ao impl doc S11; sem revalidateTag no branch 400. InstagramPersistDatabase deduplicado: alias de PostgresTransactionDatabase (owner único), instagramSync importa owner direto. Co-authored-by: opencode/muse-spark-1.2-contributor-free --- docs/changelog/2026-08-24-s11-polish.md | 1 + docs/plans/s11-polish-rota-de-sync-impl.md | 85 +++++++++++++++++++ .../(frontend)/api/social-feed/sync/route.ts | 9 ++ src/utilities/socialFeed/instagramFeed.ts | 10 ++- src/utilities/socialFeed/instagramSync.ts | 8 +- 5 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 docs/changelog/2026-08-24-s11-polish.md create mode 100644 docs/plans/s11-polish-rota-de-sync-impl.md diff --git a/docs/changelog/2026-08-24-s11-polish.md b/docs/changelog/2026-08-24-s11-polish.md new file mode 100644 index 00000000..95bba42c --- /dev/null +++ b/docs/changelog/2026-08-24-s11-polish.md @@ -0,0 +1 @@ +**S11-POLISH (#761) — rota de sync: 400 em não-configurado e tipo de persist deduplicado:** `POST /api/social-feed/sync` agora devolve `400 { ok:false, error }` quando o Instagram não está configurado (antes `200 { ok:false, status:{} }`), alinhando código ao impl doc da S11 (`400` previsto) — o painel `InstagramSyncStatusPanel` já tratava `!response.ok` e nem renderiza o botão nesse estado, então é correção de contrato HTTP observável só via curl/test direto (sem `revalidateTag` no branch 400). `InstagramPersistDatabase` (`instagramFeed.ts:335`) deduplicado: virou alias `export type InstagramPersistDatabase = PostgresTransactionDatabase` (`postgresTransactionLocks.ts:6`, owner único, `import type` sem custo runtime, `@deprecated` — preferir o owner), `instagramSync.ts` passa a importar `PostgresTransactionDatabase` direto. Débitos 3 (`jsonb` last-writer-wins, janela 300s auto-curável) e 4 (`admin.e2e` guard `· 5 posts`) permanecem defer/descartar com gatilho documentado no impl plan. diff --git a/docs/plans/s11-polish-rota-de-sync-impl.md b/docs/plans/s11-polish-rota-de-sync-impl.md new file mode 100644 index 00000000..5a0d162c --- /dev/null +++ b/docs/plans/s11-polish-rota-de-sync-impl.md @@ -0,0 +1,85 @@ +# Impl: S11-POLISH — rota de sync: 200 ok:false em não-configurado e tipo de persist duplicado + +Status: aprovado +Atualizado em: 2026-08-24 +Issue: #761 +Intenção: body da Issue #761 (débito P3 do /simplify da S11, sem plano externo) +Appetite restante: ~0.5 dia (polish P3, sem migration) + +## Leitura da intenção + +- **Outcome:** alinhar o contrato da rota `POST /api/social-feed/sync` com o impl doc da S11 e eliminar a duplicação de tipo `InstagramPersistDatabase` ↔ `PostgresTransactionDatabase` sem mudar comportamento observável da home, da global ou do painel. +- **O que NÃO negociar:** home permanece fail-closed (YouTube+artigos quando IG falha); token nunca fora do admin; sem wizard OAuth; painel só mostra estado persistido (5 estados do rascunho); sem histórico/alertas. Achados 3 (`jsonb` last-writer-wins) e 4 (`admin.e2e` guard `· 5 posts`) ficam registrados e fora deste escopo — o issue body já os marca como débito absorvido/defer. +- **O que reavaliar:** hipótese do débito 1 de que o desacoplamento via tipo duplicado evita dependência do módulo de locks — na prática `instagramSync.ts` já importa `getPostgresTransactionDatabase`; tipo é `type-only`, sem custo de runtime. Hipótese do débito 2 de que `200 { ok:false, status:{} }` é defensivo porque o botão não renderiza quando `!configured` — verdade para UI, mas o contrato HTTP deve ser 400 para caller direto (curl/test) como o impl doc previa. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[POST /api/social-feed/sync] --> B{isInstagramFeedConfigured?} + B -->|não| C[400 { ok:false, error }] + B -->|sim| D[syncInstagramFeed] + D --> E[200 { ok, status }] + F[instagramFeed.ts
InstagramPersistDatabase] -.-> G[postgresTransactionLocks.ts
PostgresTransactionDatabase
owner] + G --> F +``` + +**Opções consideradas:** A | B | C +**Recomendação:** A — rota devolve 400 com `error` quando IG não configurado; tipo de persist deduplicado via alias `InstagramPersistDatabase = PostgresTransactionDatabase` cujo owner é `postgresTransactionLocks.ts`. +**Rejeitadas:** B) manter `200` e atualizar impl doc para documentar `200` (esconde erro de cliente como sucesso; quebra semântica REST e diverge de todo outro guard da rota que usa 4xx) | C) extrair interface genérica `DatabaseWithExecute` em `utilities/db.ts` (1 nível a mais para 2 call sites; viola depth check — o owner já existe e é `postgresTransactionLocks.ts`). + +### Decisões de engenharia + +1. **Status HTTP para não-configurado: 400 em vez de 200.** + Opções: A) rota retorna `400 { ok:false, error:'Instagram não configurado' }` quando `syncInstagramFeed` indica não-configurado | B) manter `200 { ok:false, status:{} }` e corrigir impl doc | C) rota chama `isInstagramFeedConfigured` antes do sync e retorna 400 sem chamar `syncInstagramFeed`. + Recomendação: A com variante leve de C — a rota checa `isInstagramFeedConfigured` via `syncInstagramFeed` já fazer o check: o mais barato é fazer a rota inspecionar o outcome: se `!ok && status vazio` → 400. Alternativa ainda mais explícita: a rota re-lê a global e checa `isInstagramFeedConfigured` antes de chamar `sync` (1 read extra mas sem custo de API) e retorna 400. Escolher a variante sem read extra (outcome vazio → 400) para não adicionar query. + Rejeitadas: B porque 200 mente sobre sucesso e o painel já trata `!response.ok` como erro (linha 101 do `InstagramSyncStatusPanel.tsx`: `if (!response.ok || body.error) throw`) — 400 cai no caminho de erro existente sem mudar UI; C adiciona round-trip desnecessário quando o sync já sabe que não está configurado. + → Contrato: `POST /api/social-feed/sync` → não-configurado: `400 { ok:false, error:'Instagram não configurado — informe token e ID da conta.' }`; configurado: `200 { ok, status }` (sucesso ou falha da Graph API). `revalidateTag` só roda no caminho 200. + +2. **Deduplicação de tipo `InstagramPersistDatabase`.** + Opções: A) `instagramFeed.ts` importa `PostgresTransactionDatabase` e re-exporta `export type InstagramPersistDatabase = PostgresTransactionDatabase` (alias compatível) | B) remover `InstagramPersistDatabase` e migrar todos os consumidores para `PostgresTransactionDatabase` | C) manter duplicação. + Recomendação: A — owner único (`postgresTransactionLocks.ts`), `instagramFeed.ts` vira alias type-only (sem duplicar shape `{ execute }`), `instagramSync.ts` passa a importar `PostgresTransactionDatabase` diretamente para `resolvePersistDatabase`. Mantém compatibilidade de import path para quem ainda importa `InstagramPersistDatabase` de `instagramFeed.ts` (nenhum teste externo hoje, mas sem churn). + Rejeitadas: B exige mudar assinatura pública de `persistInstagramSnapshot`/`persistInstagramSyncStatus`/`persistInstagramAccessToken` em 3 lugares só por rename — churn sem ganho; C mantém débito técnico exatamente como reportado (2 definições estruturais idênticas). + → Futura remoção do alias pode ser feita quando todos os call sites migrarem, sem pressa. + +### Componentes / mudanças + +- **`src/utilities/socialFeed/instagramFeed.ts`** (editar): importar `PostgresTransactionDatabase` de `@/utilities/postgresTransactionLocks`; substituir definição estrutural de `InstagramPersistDatabase` por `export type InstagramPersistDatabase = PostgresTransactionDatabase` (alias + comentário apontando owner); assinaturas de `persistInstagramSyncStatus`/`persistInstagramSnapshot`/`persistInstagramAccessToken` passam a usar `PostgresTransactionDatabase | null` (via alias mantém compat). +- **`src/utilities/socialFeed/instagramSync.ts`** (editar): importar `PostgresTransactionDatabase` (ou manter alias via `instagramFeed.ts`); `resolvePersistDatabase` retorna `Promise`; sem mudança de lógica. +- **`src/app/(frontend)/api/social-feed/sync/route.ts`** (editar): após `syncInstagramFeed`, se `!outcome.ok && Object.keys(outcome.status).length === 0` → `400 { ok:false, error:'Instagram não configurado — informe token e ID da conta.' }` (sem `revalidateTag`); senão `200 { ok, status }` + `revalidateTag`. Mantém 401/403/500 existentes. +- **`tests/unit/instagramSync.unit.spec.ts`** (editar/estender): novo caso "rota retorna 400 quando não configurado" não cabe em unit sync — cobrir em teste de rota leve ou em unit da rota se existir; no sync unit, garantir que `outcome` vazio ainda é `{}` para o gate da rota funcionar. Opcional: teste de tipo (tsc) confirma alias. +- **`docs/plans/feed-instagram-nao-aparece-home-impl.md`** (sem alteração necessária): já previa `400 { ok:false, error }` — após fix o código alinha, doc fica correto. +- **Migration:** sem migration (sem coluna nova). +- **Access / Consent:** sem mudança (rota já exige `users` + `isSameOriginRequest`; sem PII novo). +- **UI:** sem mudança visual no painel; `InstagramSyncStatusPanel.tsx` já trata `!response.ok` como erro — 400 cai no `throw` → `requestError` genérico, mas botão nem renderiza quando `!configured` (early return), então 400 só é observável via curl/test direto, como pretendido. + +### Dados → forma (se aplicável) + +Não aplicável — sem KPI/métrica nova. Status continua shape `{ lastSyncAt?, postCount?, error?, errorAt? }` jsonb. + +## Fases verificáveis + +1. **Tipo + rota (tracer)** — editar `instagramFeed.ts` alias + `instagramSync.ts` import + `route.ts` branch 400; `pnpm tsc --noEmit`; `pnpm test` (unit `instagramSync`); curl manual: `POST /api/social-feed/sync` sem credenciais → 400 com `error`, com credenciais stub ok → 200. +2. **Gates** — `pnpm gate:fast` (lint+type+unit); `pnpm test` full local; `pnpm build` local (importmap); sem e2e novo obrigatório — e2e existente de sync deve continuar verde (retry quando configured). + +## Rabbit holes / Não escopo (engenharia) + +- Não versionar `instagramSyncStatus` com seq/timestamp para mitigar last-writer-wins (achado 3) — defer com gatilho: se regressão for observada em item futuro, versionar. +- Não mudar `admin.e2e.spec.ts` assert `· 5 posts` (achado 4) — guard intencional. +- Não extrair módulo genérico de DB nem mover `resolvePersistDatabase` para lib compartilhada. +- Não paginar feed nem tocar no refresh-token flow. +- Não adicionar histórico de sync nem dashboard. + +## Riscos e mitigação + +- **Rota 400 quebra painel que espera 200:** mitigado — painel já confere `!response.ok` (linha 101) e mostra `requestError`; e o botão não existe quando `!configured`, então o 400 nunca é exercitado pela UI normal. Teste manual do painel em 3 estados (não configurado/desativado/falha) confirma. +- **Alias type quebra build:** `PostgresTransactionDatabase` é `server-only` mas type-only import é eliminado em build; confirmar `pnpm build` e `payload generate:types` sem erro. +- **Outcome vazio colide com falha real da Graph API que também retorna status vazio:** não colide — falha real sempre escreve `failedInstagramSyncStatus` com `error`+`errorAt`, nunca `{}`; só o caminho não-configurado retorna `{}`. + +## Aceite de engenharia + +- [ ] Aceite de produto ainda coberto: botão só quando configured; rota 400 para não-configurado alinha impl doc ↔ código; tipo deduplicado sem mudar semântica de persist; painel inalterado visualmente +- [ ] Invariantes AGENTS/engineering-standards: sem nova collection; sem Consent novo; `overrideAccess:false` preservado; rota mantém `isSameOriginRequest` + `payload.auth` users-only; alias type-only sem runtime coupling +- [ ] Testes de domínio previstos: unit do sync (status vazio) + verificação manual da rota 400 vs 200; tsc gate + +Self-score decision-quality: 5/5 (opções + rejeitadas em 2 decisões caras, appetite respeitado, rabbit holes nomeados, reusa owner existente, outcome preservado). diff --git a/src/app/(frontend)/api/social-feed/sync/route.ts b/src/app/(frontend)/api/social-feed/sync/route.ts index a4a22a05..54585374 100644 --- a/src/app/(frontend)/api/social-feed/sync/route.ts +++ b/src/app/(frontend)/api/social-feed/sync/route.ts @@ -33,6 +33,15 @@ export async function POST(request: Request): Promise { const outcome = await syncInstagramFeed(payload, { signal: AbortSignal.timeout(INSTAGRAM_SYNC_TIMEOUT_MS), }) + // `syncInstagramFeed` returns `{ ok: false, status: {} }` only when + // `isInstagramFeedConfigured` is false (no token/ID) — no snapshot/status + // was persisted, so no cache to bust. + if (!outcome.ok && Object.keys(outcome.status).length === 0) { + return NextResponse.json( + { ok: false, error: 'Instagram não configurado — informe token e ID da conta.' }, + { status: 400 }, + ) + } revalidateTag(REVALIDATE_SOCIAL_FEED_TAG) return NextResponse.json({ ok: outcome.ok, status: outcome.status }) } catch { diff --git a/src/utilities/socialFeed/instagramFeed.ts b/src/utilities/socialFeed/instagramFeed.ts index 13af5afe..e46addc9 100644 --- a/src/utilities/socialFeed/instagramFeed.ts +++ b/src/utilities/socialFeed/instagramFeed.ts @@ -1,6 +1,7 @@ import 'server-only' import type { SocialFeedSetting } from '@/payload-types' +import type { PostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks' import { sql } from '@payloadcms/db-postgres' import type { Payload } from 'payload' @@ -331,10 +332,13 @@ export const describeInstagramError = (cause: unknown): string => { * 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. + * + * Owner is `PostgresTransactionDatabase` (`postgresTransactionLocks.ts`); + * this alias keeps the historical import path stable — prefer the owner type + * for new code. + * @deprecated Use `PostgresTransactionDatabase` directly. */ -export type InstagramPersistDatabase = { - execute: (query: ReturnType) => Promise -} +export type InstagramPersistDatabase = PostgresTransactionDatabase const countAffected = (result: unknown): number | null | undefined => (result as { rowCount?: number | null } | undefined)?.rowCount diff --git a/src/utilities/socialFeed/instagramSync.ts b/src/utilities/socialFeed/instagramSync.ts index a319dd42..e393d097 100644 --- a/src/utilities/socialFeed/instagramSync.ts +++ b/src/utilities/socialFeed/instagramSync.ts @@ -1,7 +1,10 @@ import 'server-only' import type { SocialFeedSetting } from '@/payload-types' -import { getPostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks' +import { + getPostgresTransactionDatabase, + type PostgresTransactionDatabase, +} from '@/utilities/postgresTransactionLocks' import { describeInstagramError, failedInstagramSyncStatus, @@ -12,7 +15,6 @@ import { persistInstagramSnapshot, persistInstagramSyncStatus, successInstagramSyncStatus, - type InstagramPersistDatabase, type InstagramSyncStatus, } from '@/utilities/socialFeed/instagramFeed' import type { Payload, PayloadRequest } from 'payload' @@ -56,7 +58,7 @@ export const instagramCredentialsChanged = ( const resolvePersistDatabase = async ( payload: Payload, req?: PayloadRequest, -): Promise => { +): Promise => { if (!req?.transactionID) return null try { return await getPostgresTransactionDatabase(payload, req)