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.
20 KiB
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 (
readadmin-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
InstagramPostExclusionPickercomo 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 componenteadmin.components, mesmo padrão do rascunho (caixa de status separada da lista).revalidateRequest.tsrealmente não muda (a tagsocial-feedjá cobre). O diagnóstico da ocorrência em produção é impossível deste worktree (sem acesso ao DB de prod —PROD_DATABASE_URLnã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
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
-
Estado persistido como
instagramSyncStatus(jsonb, campo hidden) — uma coluna, shape{ lastSyncAt?, postCount?, error?, errorAt? }. Opções: A) uma coluna jsonb (padrão doinstagramFeedSnapshot) | 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/postCountsão da última sincronização bem-sucedida;error/errorAtda ú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). -
InstagramApiError(status + apiMessage) noloadInstagramFeed+describeInstagramErrorpara copy de produto. Opções: A) o fetch parseia o body de erro da Graph API ({ error: { message, type } }) e lança erro tipado;describeInstagramErrormapeia 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;TypeErrorde fetch = rede. O mesmodescribeInstagramErrorserve 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.";TypeErrorde fetch → "Não foi possível falar com a API do Instagram (rede indisponível)."; desconhecido → "Erro inesperado ao sincronizar o Instagram."
- Mapeamentos:
-
Rota
POST /api/social-feed/syncpara o botão — auth de sessão admin + CSRF same-origin. Opções: A) route handler em(frontend)/api/social-feed/synccompayload.auth({ headers })exigindocollection === 'users'+isSameOriginRequest(helper já existente) | B) server action | C) rota sem auth (confia no botão). Recomendação: A — o precedentesameOriginRequest.tsé literalmente para "cookie-authenticated JSON route handlers";payload.authlê o cookiepayload-tokenda sessão admin (precedentecampaignAuth); 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 }; statusnull/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 chamarevalidateTag('social-feed')(fora de cache — permitido). -
Sync no
afterChangeda global quando a credencial IG muda (awaited, bounded, à prova de erro). Opções: A) hookafterChange[revalidateFeed, syncInstagramAfterChange] — sync awaited quandodoc≠previousDoceminstagramAccessToken/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 MESMOsyncInstagramFeedda rota (uma implementação, dois call sites). -
Painel
InstagramSyncStatusPanel(campoui, irmão do picker) — 5 estados. Opções: A) componente client próprio lendo os campos do form viauseAllFormFields+ estado local pós-botão | B) estender o picker | C) campojsonnão-hidden renderizado cru. Recomendação: A — o picker prova o padrão (campoui+ componente client +useAllFormFields); o painel lêinstagramSyncStatus(inicial),instagramAccessToken/instagramUserId/instagramEnablede 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 comIntl.RelativeTimeFormat('pt-BR')local (o painel é client;formatRelativePostDateé server-only —posts.tsabre comimport '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, bordasrounded-lg), shape → craft → critique → polish no navegador (admin, 3 cenas + estados). -
loadInstagramFeedganhasignal?: AbortSignal(repasse aofetchImpl). O hook precisa do deadline; oLoadInstagramFeedArgsaceitainitno fetchImpl — o signal flui sem mudar a assinatura do fetch. Unit-testado. -
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 estadofailatual 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çaInstagramApiError(status+apiMessage);loadInstagramFeedaceitasignalopcional repassado aofetchImpl(fetch do usuário, media e refresh).src/utilities/socialFeed/instagramSync.ts(novo,server-only, dentro do subfoldersocialFeed/— a pin de top-level dacodebaseConventions.unit.spec.tsnã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 deunstable_cacheno render — sem hooks).syncInstagramFeed(payload, { signal? }): Promise<{ ok: true; status } | { ok: false; status }>— lê a global, retorna "não configurado" sem tocar na API quandoenabled === false/instagramEnabled === false/sem token/ID; sucesso →persistInstagramSnapshot+ persist token renovado (reuso dos privados? não — ospersist*atuais são module-private doinstagramFeed.ts; exportarpersistInstagramSnapshot/persistInstagramAccessTokendoinstagramFeed.tspara o sync module — o dono do persist é o feed, o sync é o orquestrador) + status de sucesso; falha → status de erro viadescribeInstagramError; 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): campoinstagramSyncStatus(json,admin.hidden: true— o painel renderiza, o dado não aparece cru); campouiinstagramSyncStatusPanel(apósinstagramEnabled, 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 })exigindouser.collection === 'users'(401 sem sessão admin); lê a global, IG desconfigurado →400 { ok: false, error };syncInstagramFeedcomAbortSignal.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-busyno 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/usere/media(e refresh → 400);POST /__stub/stateaceita o novo estado.- Migration:
add_instagram_sync_status(jsonb nullable). Types/importmap:pnpm generate:types+pnpm generate:importmap(com envsS3_*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
- Schema + servidor (~metade do appetite): migration +
generate:types;InstagramApiError+ parse do body +signalnoloadInstagramFeed;instagramSync.ts(status builders +describeInstagramError+ persist SQL +syncInstagramFeed); persistência de status nogetInstagramFeed; rota/api/social-feed/sync. Unit tests:describeInstagramError(OAuthException→token, invalid-user→ID, 5xx→indisponível, TypeError→rede, unknown→inesperado),loadInstagramFeedcom fetchImpl 400+body de erro lançandoInstagramApiError, repasse designal, builders de status, gateinstagramCredentialsChanged(doc/previousDoc: token mudou, ID mudou, enabled false→true, exclusão não dispara). Gates parciais: tsc +pnpm test. - Painel + hook + runbook (restante):
InstagramSyncStatusPanel+ campoui+ hookafterChangecom sync awaited; stubinvalid-token; runbookdocs/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 dosocial-feed). Polish no navegador (admin, 3 cenas). - e2e + gates finais: admin smoke — seed com token+ID → painel "Aguardando a primeira sincronização" → clique → "Sincronizado" com contagem + assert do
instagramSyncStatusvia REST; stubinvalid-token→ falha com copy do token + botão; stubfail→ falha 500; reset do helperresetSocialFeedSettingsganhainstagramSyncStatus: 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 buildlocal, e2e no CI; changelogdocs/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
loadInstagramFeedjá 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 subfoldersocialFeed/já documentado).
Riscos e mitigação
- Rota auth no admin:
payload.auth({ headers })com cookiepayload-token— precedentecampaignAuth; 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). revalidateTagdentro de cache: a persistência de status no render usa SQL cru (sem hooks) — mesma restrição documentada do snapshot (Next 15.4 lançarevalidateTagdentro deunstable_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-feedjá 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)