fix(social-feed): S11-POLISH rota sync 400 em não-configurado e dedup InstagramPersistDatabase (Closes #761)

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
This commit is contained in:
Francisco Solla 2026-08-24 17:22:41 -03:00
parent a79d2d3790
commit 820824a6c4
5 changed files with 107 additions and 6 deletions

View file

@ -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.

View file

@ -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<br/>InstagramPersistDatabase] -.-> G[postgresTransactionLocks.ts<br/>PostgresTransactionDatabase<br/>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<PostgresTransactionDatabase | null>`; 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).

View file

@ -33,6 +33,15 @@ export async function POST(request: Request): Promise<NextResponse> {
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 {

View file

@ -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<typeof sql>) => Promise<unknown>
}
export type InstagramPersistDatabase = PostgresTransactionDatabase
const countAffected = (result: unknown): number | null | undefined =>
(result as { rowCount?: number | null } | undefined)?.rowCount

View file

@ -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<InstagramPersistDatabase | null> => {
): Promise<PostgresTransactionDatabase | null> => {
if (!req?.transactionID) return null
try {
return await getPostgresTransactionDatabase(payload, req)