fix(C220): link do proprio Instagram baixa e cataloga a peca (Closes #1302)

This commit is contained in:
Francisco Solla 2026-09-24 03:19:14 -03:00
parent 76416c77b0
commit 8f3118312a
20 changed files with 19518 additions and 121 deletions

View file

@ -0,0 +1 @@
**C220 — Central de Conteúdos: o link do próprio Instagram baixa e cataloga a peça (2026-09-24):** colar o link de uma peça do próprio perfil (`@depjorgesolla`) na Central (`/campanha/comunicacao/conteudos`) deixava de baixar e analisar em silêncio — virava "peça-link" sem motivo. O parse de link (`lib/contentPiece.ts`) passa a aceitar também a grafia que o browser copia do perfil (`/<perfil>/reel/<shortcode>/`, `p|reel|reels|tv`, com/sem `www.`/`m.`, query ignorada) e rejeita `/stories/`, `instagr.am` e hosts fora de Instagram/YouTube como antes; o match com a mídia oficial passa a ser pelo **shortcode** (o kind é apresentação — `/p/ABC/` casa o reel), o que também garante que link de terceiro nunca baixa. O cliente Graph API (`instagramFeed.ts`) ganhou paginação por cursor com janela total declarada: `maxResults` continua o teto por página (50 ⇒ exatamente uma chamada, board intocado) e o resolver da peça pede **500 mídias** (`CONTENT_PIECE_LINK_INSTAGRAM_WINDOW`, early-stop no match, `paging.next` ignorado de propósito — leva o token na URL); refresh de token segue o contrato do dono e o `refreshedAccessToken` agora é persistido best-effort pelo helper existente. Quando a extração não acontece, a resolução devolve um **motivo honesto** em vocabulário fechado (novo select `linkFailureReason` + migration `20260924_045625_add_content_piece_link_failure_reason`): `nao-encontrado` ("Link não encontrado entre as mídias recentes do perfil"), `carrossel`, `indisponivel` e `sem-credencial` — com o teto de 4 GB da casa, storage e ffmpeg seguindo `falhou`/Reprocessar (sentinela `ContentPieceMediaTooLargeError` distingue o cap do transporte) e YouTube permanecendo link-only **sem** motivo. O job é o único escritor do motivo no write final `pronto`; anexar o arquivo original e o retry limpam o campo. A ficha porta o design aprovado classe-a-classe: placeholder "Buscando a mídia no perfil oficial…", barra "Procurando a publicação no perfil oficial…", card "Processamento da peça" com os três passos no desktop, card verde "Arquivo e transcrição prontos"/"Arquivo pronto" quando a mídia chega, e o card âmbar "Por que o arquivo não foi baixado" com o literal + "A peça continua disponível pelo link." + Anexar; a lista fica limpa, com o rótulo neutro "Instagram · Peça-link" no card mobile (o tipo não é repetido quando é palpite). Crítica final do `designer` certificada em tier primário (`openai/gpt-5.6-sol`) após corrigir o tracker no mobile (`hidden md:block`). Verificação: unit (`contentPiece` com parse prefixado/rejeições/labels verbatim/view model, `instagramFeed` com cursor/paginação/1 página no board), int (`contentPiece`: resolver com deps injetadas para match/carrossel/sem credencial/feed e download falhos/sentinela do teto, job/attach/retry persistindo e limpando o motivo), e2e (`campaignSpeechAcervo`: ficha mostra o literal, lista não), `pnpm gate:fast`, `pnpm test:int` (946) e o e2e afetado local.

View file

@ -0,0 +1,183 @@
# Impl: C220 — Central de Conteúdos — link do Instagram do deputado baixa e cataloga a peça
Status: aprovado
Atualizado em: 2026-09-24
Issue: #1302
Intenção: docs/plans/central-conteudos-link-instagram.md
Appetite restante: herdado (~1,5–2 dias eng) — sem corte novo: o plano reusa o pipeline C211 e o cliente Graph API existentes; varredura, backfill, scraping e álbum de carrossel seguem fora (intenção).
> Modo `--auto` (work-issue): plano nasce aprovado pelo agente. Design UI do gate: `docs/plans/central-conteudos-link-instagram-ui-design.html`. Trigger de designer declarado em **D7: non-trigger** — se a execução encontrar um estado não desenhado, o designer estende o artefato ANTES do markup (não improvisar estrutura visual).
## Leitura da intenção
- **Outcome:** colar um link do próprio perfil do deputado (`@depjorgesolla`) nas formas que o browser copia resulta em peça com mídia baixada, transcrita e catalogada — igual ao envio por arquivo; quando a extração não acontecer, a ficha mostra **um dos quatro motivos literais** e a peça continua peça-link (`pronto`, nunca "Falhou", nunca em silêncio).
- **O que NÃO negociar:**
- Extração **só** por caminho oficial (Graph API da própria conta, `src/utilities/socialFeed/instagramFeed.ts`); nunca scraping/contorno; **mídia de terceiro nunca é baixada**; YouTube **link-only sem motivo** (a Data API não entrega arquivo).
- Motivos **verbatim** e peça-link como resultado legítimo; `sourceUrl` canônico é a identidade (sem peça gêmea).
- Board/feed da home **intocado**: sem cache 5 min, snapshot, kill switch, lock transacional do hook e sem `@payload-config` em `instagramFeed.ts` (`instagramFeedView.ts:44-48,96`).
- `curatedFields` da assessoria nunca sobrescritos e **não ganha o campo de motivo**; `error` cru nunca chega à pessoa; token nunca em log.
- Despublicar/peça-link nunca apaga arquivo nem peça; "Anexar arquivo original" segue como caminho.
- LGPD: **sem Consent novo** (não há PII de eleitor; a peça é material de campanha).
- **O que reavaliar (hipóteses da intenção, confirmadas ou resolvidas no código):**
- "o parse rejeita caminho prefixado" — confirmado: `parseContentPieceLink` desestrutura `[kind, shortcode]` por posição (`src/lib/contentPiece.ts:373-386`); muda.
- "a extração degrada em silêncio" — confirmado: 5 ramos devolvem `{media:null,caption}` sem motivo (`contentPieceLink.ts:114-155`) e o job grava `pronto/step:null/error:null` (`contentPieceJob.ts:262-299`).
- "não há paginação" — confirmado: `parseInstagramMediaResponse` descarta `paging` (`instagramFeed.ts:148-185`) e o `limit` é capado em 50 (`:228`); o `maxResults` do C212 continua em aberto contra a API real.
- "persistir motivo exige campo novo" — confirmado: `error` é interno e só renderiza em `falhou` (`[id]/page.tsx:175-182`; `lib/contentPiece.ts:517` mapeia `error→failureMessage` sempre) ⇒ campo novo + migration.
- **Assumidas da intenção (recomendações A):** janela de busca paginada com limite declarado (D2); motivo **só na ficha** (D7); links prefixados pelo perfil aceitos (D3/parse).
- Hipótese "motivo só na ficha" × design cena 3 ("Instagram · Peça-link" na lista): resolvida em D7 — motivo nunca na lista; a lista mobile ganha só o rótulo neutro desenhado.
## Abordagem recomendada
```mermaid
flowchart LR
D["Dialog 'Adicionar por link'<br/>cole do próprio perfil"] --> P["parseContentPieceLink<br/>(p|reel|reels|tv · perfil prefixado · query ignorada)"]
P --> A["action cria contentPiece<br/>processando/extraindo · sourceUrl canônico"]
A -->|"Instagram"| J["after() → runContentPieceJob<br/>resolveContentPieceSource"]
A -->|"YouTube"| Y["peça-link sem motivo<br/>(link-only por desenho)"]
J --> C{"Credencial<br/>configurada?"}
C -- "não" --> R4["motivo: sem-credencial"]
C -- "sim" --> W{"shortcode na janela de 500<br/>(cursor · early-stop)"}
W -- "sem match" --> R1["motivo: nao-encontrado"]
W -- "carrossel" --> R2["motivo: carrossel"]
W -- "imagem/vídeo/reel" --> DL["baixa media_url<br/>(teto 4 GB · sem scraping)"]
DL -- "HTTP/transporte falhou" --> R3["motivo: indisponivel"]
DL -- "ok" --> M["contentMedia + transcrição<br/>+ catalogação (igual ao arquivo)"]
R1 --> F["pronto · step null · error null<br/>linkFailureReason persistido"]
R2 --> F
R3 --> F
R4 --> F
M --> OK["pronto · motivo null"]
Y --> OK
F --> UIF["ficha: 'Por que o arquivo não foi baixado'<br/>+ Anexar arquivo original"]
OK --> UIO["ficha: 'Arquivo e transcrição prontos'"]
F --> UL["lista: só 'Instagram · Peça-link'"]
```
**Opções consideradas (geral):** A) paginação por cursor no dono do cliente Graph API + motivo persistido em select novo + resolução do link que devolve motivo (sem lançar) + ficha desenhada; B) scraping/oEmbed/segundo fetch ou download de terceiro; C) manter tudo como está (só forma canônica; falha = `falhou`).
**Recomendação:** **A** — mantém um dono por mecanismo (feed Graph API, pipeline da peça, ficha), usa o seam de dependências injetáveis que o C211 já criou para o resolver (`contentPieceLink.ts:87-105`), nasce fail-closed e cabe no appetite.
**Rejeitadas:** **B** por invariante (nunca scraping/terceiro); **C** porque não cumpre o aceite (link do perfil continua degradando) nem "motivo honesto" — e não exige schema novo justamente onde o silêncio nasce.
### D1 — Persistência do motivo: campo enum novo + migration
**Opções:** A) select novo `linkFailureReason` com 4 valores pt-BR sem acento (`nao-encontrado | carrossel | indisponivel | sem-credencial`), labels = literais verbatim, nullable/readOnly/sem default; B) `text` livre guardando o literal; C) reusar `error`.
**Recomendação:** **A** — vocabulário fechado e fail-closed, DB restringe, a UI só mapeia label; espelha `processingStatus`/`step` (valores pt-BR + labels acentuados, precedente do C211) e preserva `error` como causa crua interna (`ContentPiece.ts:397-405`).
**Rejeitadas:** **B** — texto livre deriva (jargão, literal torto) e a UI teria de adivinhar o motivo de volta; **C** — quebra a separação raw×produto: `error` é stderr/transporte e só renderiza em `falhou`; usá-lo num estado `pronto` confundiria os dois ciclos (e violaria "nunca com o detalhe cru").
**Migration:** `pnpm migrate:create add_content_piece_link_failure_reason` → `20260924_<HHMMSS>_add_content_piece_link_failure_reason` (enum + coluna nullable; **sem backfill** — peça-link antiga cai no card genérico; reprocessar acervo é não escopo). `pnpm migrate` local + `pnpm generate:types` (payload-types é versionado). Migration existente nunca editada.
### D2 — Janela de busca: paginação por cursor no dono, sem acoplar ao board
**Opções:** A) dar paginação por cursor ao `loadInstagramFeed` (o dono do cliente Graph API): `maxResults` vira a **janela total** e cada página pede `limit = min(maxResults, 50)` ≤ 50, avançando por `paging.cursors.after` enquanto faltar e houver cursor; `maxResults ≤ 50` ⇒ exatamente 1 página (board/sync intactos); B) função nova no mesmo módulo (twin interno de refresh/parse); C) o resolver chamar a Graph API por conta própria.
**Recomendação:** **A**, com janela declarada de **500 mídias** (`CONTENT_PIECE_LINK_INSTAGRAM_WINDOW = 500`, em `contentPieceLink.ts`): ~10 páginas de 50, early-stop no match; a chamada típica é **1** (a assessoria cola o post recém-publicado); 500 ≈ 1–2 anos de ritmo, bem dentro do teto de 10K da edge (C212 §Q1) e com custo máximo ≤10 chamadas por colagem. Detalhes: `parseInstagramMediaPage` (novo; mesma validação de protocolo; devolve `{ posts, nextCursor }` lendo `paging.cursors.after`; cursor ausente/malformado ⇒ para) e `parseInstagramMediaResponse` fica como wrapper (testes e `nextImageRemotePatterns`). `paging.next` é **ignorado** de propósito (leva `access_token` na URL — token nunca em log). Refresh segue o contrato atual: falha em qualquer página ⇒ um refresh e refaz a janela do zero.
**Rejeitadas:** **B** — duas implementações do mesmo cursor/refresh/erro no mesmo dono; **C** — duplicaria auth/refresh/erro tipado e jogaria fora o seam injetável do C211.
**Risco declarado:** se o host do Instagram Login só oferecer paginação time-based (C212 "a confirmar"), a janela efetiva cai para a 1ª página (50) e o motivo continua honesto — sem quebra; verificação viva no staging/produção (Riscos).
### D3 — Matching shortcode ↔ mídia (permalink)
**Opções:** A) identidade pelo **shortcode** (`parseContentPieceLink(post.permalink)?.shortcode === link.shortcode`); B) igualdade de `canonicalUrl` (comportamento atual, `contentPieceLink.ts:144-146`); C) comparar id numérico da mídia.
**Recomendação:** **A** — o shortcode é a identidade declarada (`lib/contentPiece.ts:354-359`) e o kind (`p` × `reel`) é apresentação: com **B**, um link `/p/ABC/` colado para um reel nunca casa; **A** casa as duas grafias e, por casar **dentro do feed da conta própria**, é também o que garante "mídia de terceiro nunca baixada" (link de terceiro ⇒ sem match ⇒ motivo `nao-encontrado`).
**Rejeitadas:** **B** — falso-negativo por kind; **C** — o id numérico não está no link colado.
**Parse (muda junto):** aceitar `[kind, shortcode]` **ou** `[perfil, kind, shortcode]` (só essas duas formas), com `p|reel|reels|tv`, `www.`/`m.`, query/hash ignorados; rejeitar `/stories/` (inclusive quando `stories` for o "perfil" de um caminho forjado — primeiro segmento reservado/kind nunca é perfil), `instagr.am` e hosts fora de IG/YouTube; canônico continua `https://www.instagram.com/<kind canônico>/<shortcode>/`.
### D4 — Falha da extração: motivo, não `falhou` (e `!mediaUrl`)
**Opções:** A) falhas **do lado Instagram** (sem credencial; `loadFeed` lança; sem match na janela; carrossel; `!mediaUrl`; erro HTTP/transporte do download) devolvem `media:null` + motivo e a peça termina `pronto` (peça-link); falhas **locais** (criar `contentMedia`, exceder o teto de 4 GB, ffmpeg/transcrição) continuam lançando ⇒ `falhou` + "Reprocessar"; B) tudo vira motivo; C) manter o throw atual do download.
**Recomendação:** **A** — cumpre "peça-link é resultado legítimo, não 'Falhou'" e deixa `falhou` só para o que o reprocessar/arquivo resolve. Para post **encontrado** mas sem `mediaUrl` (ex.: áudio protegido omite o campo), o motivo é `indisponivel` (não é carrossel; a API achou mas não entregou o arquivo) — micro-decisão registrada, reversível. Nenhum desses ramos lança: o `catch` do job (`contentPieceJob.ts:300-306`) segue reservado a falhas reais.
**Nota de implementação:** o teto de bytes precisa ser distinguível de falha de transporte no mesmo `pipeline` — usar um erro sentinela do dono (`ContentPieceMediaTooLargeError`, `contentPieceLink.ts`) que o resolver relança; qualquer outra falha do stream vira `indisponivel`. Assim o cap continua `falhou` (retry não resolve, anexar resolve) e a rede do Instagram vira motivo honesto.
**Rejeitadas:** **B** — mascararia falha nossa/storage como problema do Instagram e mataria o "Reprocessar"; **C** — deixa a extração falha sem motivo (o defeito que o item conserta) e converte indisponibilidade transitória em "Falhou".
### D5 — Token refresh
**Opções:** A) persistir o `refreshedAccessToken` que o `loadInstagramFeed` já devolve, via `persistInstagramAccessToken` (owner, raw SQL sem hooks), best-effort; B) ignorar (status quo: `contentPieceLink.ts:130-135` descarta); C) writer novo.
**Recomendação:** **A** — é o mesmo helper do board, **não** é o caminho do sync (sem snapshot, kill switch, cache ou lock); evita refresh repetido a cada colagem; falha ao persistir não derruba a extração (`.catch`); token nunca vai a log (nenhuma URL com `access_token` é logada/armazenada). O UPDATE pode esperar o row lock do global se o hook do board estiver sincronizando naquele instante — é espera, não deadlock; best-effort resolve.
**Rejeitadas:** **B** — gasta refresh à toa e pode expirar sem o board rodar; **C** — twin.
### D6 — Ciclo de vida do motivo (onde seta e limpa)
**Opções:** A) o resolver devolve `linkFailureReason` e **o job é o único que persiste**, no write final `pronto` (com `step:null/error:null`): motivo quando `!media` e origem Instagram; `null` quando há mídia, quando origem ≠ Instagram (YouTube/arquivo) ou quando não se aplica; `attachContentPieceMedia` limpa no mesmo update que grava `media` (`contentPieceUpload.ts:273-286`); o retry (`contentPieces.ts:185-195`) limpa ao reiniciar; o motivo **não** entra em `CONTENT_PIECE_CURATED_FIELDS`; B) o resolver escreve direto na linha; C) limpar só no anexo.
**Recomendação:** **A** — um escritor (o job), coerente com "o pipeline é dono do estado"; **B** mistura I/O de resolução com estado da linha; **C** deixa motivo obsoleto quando o retry reencontra a mídia.
**YouTube:** nunca tem motivo — o branch origem ≠ Instagram devolve `null` (link-only por desenho, coberto por teste).
### D7 — Superfície de UI (ficha × lista) e trigger de designer
**Opções:** A) motivo **só na ficha**; a lista não tem motivo, mas a card mobile ganha o rótulo neutro desenhado ("Instagram · Peça-link") derivado de `origin`+`hasFile` (sem campo novo); B) selo de motivo na lista; C) lista intocada.
**Recomendação:** **A** — a cena 3 do design do gate desenha exatamente isso ("Instagram · Peça-link" + recomendação "a lista continua limpa; o motivo completo vive na ficha") e a pergunta em aberto da intenção era sobre o **motivo** na lista (recomendação A). O card de motivo desktop ("Motivo da peça-link") e o mobile ("Por que o arquivo não foi baixado") viram **um só** card, com o literal mobile como título — evita duas estruturas divergentes.
**Trigger de designer: `non-trigger` (declarado).** Tudo que o código real exige já está desenhado: cena 1 (processando com 3 passos + "Buscando a mídia no perfil oficial…" + estado "Arquivo e transcrição prontos"), cena 2 (motivo + "Anexar arquivo original") e cena 3 (mobile, motivo e lista compacta). Duas **adaptações de copy** (honestidade, sem estrutura nova, registradas): (i) o card verde usa "Arquivo e transcrição prontos" só quando há transcrição, senão "Arquivo pronto" (o desenho é de um reel); (ii) o corpo não afirma "a mídia oficial foi baixada" quando o arquivo pode ter vindo do anexo. Se a execução encontrar um estado **não desenhado**, para e o designer estende o artefato antes do markup.
**Rejeitadas:** **B** — viola "lista limpa" e polui a triagem; **C** — não implementa um elemento desenhado sem necessidade.
### Componentes / mudanças
- **`src/lib/contentPiece.ts`** (puro; dono do vocabulário): `CONTENT_PIECE_LINK_FAILURE_REASONS` + type + `contentPieceLinkFailureReasonLabels` (literais verbatim) + `isContentPieceLinkFailureReason`; `CONTENT_PIECE_LINK_LABEL = 'Peça-link'`; `parseContentPieceLink` com perfil prefixado e rejeição explícita de `stories`; `ContentPieceViewModel` ganha `linkFailureReason` + `linkFailureReasonLabel` (normalização fail-closed em `toContentPieceViewModel`, sem tocar em `failureMessage`).
- **`src/utilities/socialFeed/instagramFeed.ts`** (dono do cliente Graph API): `parseInstagramMediaPage` (posts + cursor) e loop de cursor em `loadInstagramFeed` (`maxResults` = janela, página ≤ 50, default 1 página); `INSTAGRAM_MAX_RESULTS_CAP` vira teto **por página** (comentário); refresh/erro tipado/`signal` inalterados; snapshot/status/config **não** se tocam.
- **`src/utilities/content/contentPieceLink.ts`** (dono da resolução): `CONTENT_PIECE_LINK_INSTAGRAM_WINDOW = 500`; retorno com `linkFailureReason`; mapeamento dos ramos (D4); download falho ⇒ `indisponivel` (sem lançar; sentinela do cap relança); persist best-effort do refresh (D5); limite de tempo do feed (30 s) para a API pendurada não segurar `processando` até o reaper; `INSTAGRAM_MAX_RESULTS_CAP` sai do import.
- **`src/utilities/content/contentPieceJob.ts`**: write transacional final persiste `linkFailureReason` (D6); nada mais muda (transação/`req` e bypass admin já justificados em `:59-85,262-299`).
- **`src/utilities/content/contentPieceUpload.ts`**: `attachContentPieceMedia` limpa `linkFailureReason` junto de `error: null`.
- **`src/app/(campaign)/campanha/actions/contentPieces.ts`**: `linkFailureReason` no `contentPieceSelect` (`:42-59`); retry limpa (`:185-195`); `overrideAccess:false` + `user` preservados.
- **`src/utilities/content/contentPiecePageData.ts`**: `linkFailureReason` **só** no `contentPieceDetailSelect` (`:47-56`) — a lista segue sem o campo (design "lista limpa").
- **`src/collections/ContentPiece.ts`**: campo `linkFailureReason` (select, readOnly, descrição admin "Por que uma peça do Instagram ficou só como link; preenchido pelo processamento").
- **Migration:** `20260924_<HHMMSS>_add_content_piece_link_failure_reason` (D1) + `payload-types.ts` regenerado.
- **UI — `[id]/page.tsx` (ficha, Impeccable B, sem tela nova):** para `origin === 'instagram' && processando`: placeholder "Buscando a mídia no perfil oficial…" (enquanto `!hasFile`), caixa de progresso "Procurando a publicação no perfil oficial…" e card "Processamento da peça" com os 3 passos desenhados (Link recebido / Mídia oficial / Catalogação; estados Concluído/Em andamento/Aguardando derivados do `step` existente — `extraindo` = mídia oficial; `transcrevendo|catalogando|salvando` = catalogação); para `origin !== 'arquivo' && pronto && hasFile`: card verde "Arquivo e transcrição prontos"/"Arquivo pronto" com copy honesta; para `!hasFile && linkFailureReasonLabel`: card do motivo (título "Por que o arquivo não foi baixado", literal verbatim, "A peça continua disponível pelo link.", reusando `ContentPieceAttachFileButton`) **no lugar** do card genérico atual (`:165-173`), que permanece para peça-link sem motivo (YouTube/legado). `falhou` e demais estados ficam como estão. `ContentPieceStatusRefresher` intocado (a transição `pronto` já dispara o refresh).
- **UI — `ContentPieceCardList.tsx`:** linha meta dos cards mobile ganha o rótulo neutro desenhado: `origin !== 'arquivo'` ⇒ prefixa a origem; `pronto && !hasFile` ⇒ "Peça-link" (sem motivo). `ContentPieceTable` fica como está (já mostra a origem).
- **Access / Consent:** nada novo — leitura sob o gate existente (`canReadCommunicationCatalog` + access da collection); **sem Consent** (não há PII de eleitor). Nenhum helper de access muda.
- **Changelog:** `docs/changelog/2026-09-24-c220.md` (entrada curta; nunca editar o agregado/HISTORY).
### Dados → forma (se aplicável)
N/A — não há agregado, KPI ou série; o que muda é o **estado** da peça e um rótulo. Restrição da intenção mantida: motivo em linguagem de produto (um dos quatro literais), sem código de erro, stack ou jargão de API.
## Fases verificáveis
1. **Tracer / schema+server — quota ~50%:** migration + campo + vocabulário em `lib/contentPiece.ts`; `parseContentPieceLink` prefixado (unit verde); paginação de cursor em `instagramFeed.ts` (unit verde); resolver com motivo + persist do refresh; job/attach/retry. **Tracer bullet cedo:** int do resolver com `loadFeed` fake casando `/depjorgesolla/reel/ABC/` → `contentMedia` criada, motivo `null`; e sem match → `nao-encontrado`, linha `pronto`. Só então a UI.
2. **UI — quota ~30%:** ficha (processando com passos, sucesso, motivo) + rótulo neutro na card list; e2e do motivo (fixture, sem job).
3. **Gates — quota ~20%:** `pnpm gate:fast` (lint + typecheck + unit); `pnpm test:int` (suíte cheia); e2e curado (`campaignSpeechAcervo`, com a seção C211 do spec); `pnpm push` via GitHub.
### Testes previstos
- **`tests/unit/contentPiece.unit.spec.ts`:** parse aceita `/depjorgesolla/reel/ABC/` e `/depjorgesolla/p/ABC/?igsh=…` com o mesmo canônico de `/reel/ABC/`; rejeita `/stories/…`, `/stories/reel/ABC/`, `instagr.am`, perfil sem kind, host fora; labels dos 4 motivos **verbatim**; `toContentPieceViewModel` mapeia motivo conhecido → literal e desconhecido → `null`, sem alterar `failureMessage`.
- **`tests/unit/instagramFeed.unit.spec.ts`:** `parseInstagramMediaPage` (cursor presente/ausente/malformado); paginação real (2–3 páginas, `after=` na página 2, para em `maxResults`, erro na página 2 ⇒ 1 refresh e refaz a janela); `maxResults ≤ 50` ⇒ **1** chamada de mídia (board intacto); atualizar o teste do cap (`:252-258`) para pinar janela × página.
- **`tests/int/contentPiece.int.spec.ts` — resolver (deps injetadas, sem rede):** global configurado + `loadFeed` fake + `fetchImpl` fake: match por shortcode baixa e cria `contentMedia` (motivo `null`); sem match ⇒ `nao-encontrado`; carrossel ⇒ `carrossel`; sem credencial ⇒ `sem-credencial`; `loadFeed` lançando ⇒ `indisponivel`; download HTTP 500 ⇒ `indisponivel`; download acima do teto ⇒ lança (não vira motivo).
- **`tests/int/contentPiece.int.spec.ts` — job/collection:** `resolveSource` injetado com motivo ⇒ linha `pronto` + `linkFailureReason`; com mídia ⇒ motivo `null`; origem YouTube ⇒ motivo `null`; `attachContentPieceMedia` limpa o motivo; retry limpa ao reiniciar.
- **e2e (benefício real, determinístico):** na seção C211 de `tests/e2e/campaignSpeechAcervo.e2e.spec.ts`, fixture peça-link com motivo (`carrossel`) ⇒ a ficha contém o literal e "Anexar arquivo original"; a lista **não** contém o literal (pina "lista limpa"). Sem depender do job assíncrono.
- **Pins:** `tests/unit/instagramFeed.unit.spec.ts` (contrato janela/página); `scripts/lib/e2e-affected-manifest.mjs` **sem mudança** (o diff toca `src/utilities/content`, `src/components/campaign/content` e `src/lib/contentPiece` — mapeados para `campaignSpeechAcervo`; `src/utilities/socialFeed` não é prefixo de risco e cai no smoke `campaignHomeActions`).
## Rabbit holes / Não escopo (engenharia)
- **Varredura agendada/webhook/cursor próprio/backfill em massa** — item próprio (C212 §Q4); aqui só o link colado é procurado.
- **Scraping, oEmbed, segundo fetch e download de mídia de terceiro** — permanente; o resultado é motivo honesto + anexar arquivo.
- **Carrossel vira álbum** (baixar filhos, montar capa) — não; motivo `carrossel`.
- **Retry de peça-link com motivo** ("Instagram indisponível" transitório): não há affordance hoje (`canRetry` só `falhou`) e o design não desenha; **gatilho:** se a assessoria pedir "tentar de novo" numa peça-link, item curto + designer.
- **`paging.next` absoluto** (token na URL) — ignorado; só cursor. **Paginação time-based (`since/until`)** — não implementada; se for a única do host, a janela degrada para 50 com motivo honesto (item próprio se acontecer).
- **Novo painel/tela de credencial** — `SocialFeedSettings` é o dono; falta de credencial é motivo na ficha.
- **Motivo na lista/segundo selo/segunda tela; backfill de motivo em peças antigas; preview/poster da mídia extraída** — fora.
- **Tocar snapshot/cache/kill switch/lock do board** — proibido; o único ponto compartilhado é o cliente `instagramFeed.ts`, com default de 1 página.
## Débitos do `/simplify` (deferidos com gatilho)
Triagem da sessão (nada registrado como Issue: nenhum `expensive_lock` ≥4):
- **D-1 — dono puro dos passos e do predicado "peça-link".** `processingSteps`/rótulos de design inline na ficha (`[id]/page.tsx:65-95`), predicado "peça-link" re-derivado em 4+ pontos, e `mediaStepDone = step !== 'extraindo'` dizendo "Mídia oficial: Concluído" sem mídia. **Gatilho:** próximo toque da ficha/UI de processamento (corrigir o `mediaStepDone` exigindo `hasFile` junto).
- **D-2 — VM de linha × select da lista.** `linkFailureReason`/label sempre `null` nas linhas porque o select da lista não carrega o campo (omissão intencional por D7). **Gatilho:** a lista precisar do motivo (hoje proibido) ou o VM de linha separar-se do da ficha.
- **D-3 — helper/restauro nos testes int.** Repetição de `withTempDir(resolveContentPieceSource(...))` e `setInstagramSettings(false)` sem restauro em `contentPiece.int.spec.ts`. **Gatilho:** próximo toque do spec (pode pegar carona no F3 do `escala-dry-pos-c211` #1267).
- **Descartados:** wrapper `parseInstagramMediaResponse` (decisão D2: conveniência de leitura de 1 página) e a consolidação dos describes duplicados do parser (cosmético, sem risco).
## Riscos e mitigação
- **Forma da paginação no host do Instagram Login "a confirmar" (C212):** cursor ausente ⇒ 1 página e motivo honesto, sem quebra; verificação viva no staging/produção (a credencial real vive no `SocialFeedSettings` de produção; o staging pode não tê-la — registrar no runbook do token).
- **`instagramFeed.ts` é dono compartilhado com o board:** `maxResults ≤ 50` ⇒ exatamente 1 página; testes do board (user/media/refresh/abort) passam sem mudança; o diff acorda o smoke da home no CI.
- **Motivo obsoleto (extração posterior):** um escritor (job) + limpeza no anexo e no retry; testes int dedicados.
- **Clobber de curadoria:** o motivo é campo de sistema, fora de `CONTENT_PIECE_CURATED_FIELDS`; o write final do job (releitura fresca + transação) segue igual.
- **Token em log/URL:** `paging.next` ignorado; nenhuma URL com `access_token` é logada/armazenada; erros tipados não carregam URL.
- **Janela/limite mentindo:** o literal fala "mídias recentes"; a janela é declarada no código (500) e neste plano — sem promessa de "todas as mídias".
- **Download parcial/temp órfão:** mídia só nasce após o arquivo completo; `tempDir` removido no `finally` (`contentPieceJob.ts:307-309`).
- **Appetite:** o maior custo é a UI da ficha; a ordem (motivo → extração → polimento de passos) deixa o aceite coberto mesmo se o polimento atrasar.
- **CI:** diff mapeado para e2e de comunicação + smoke da home — nunca zero; rodar `gate:fast` e int local antes do push.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: link do próprio perfil (canônico e prefixado) extrai e cataloga; janela declarada com motivo honesto além dela; os 4 motivos verbatim na ficha; peça-link nunca apagada e "Anexar arquivo original" disponível; curadoria preservada; YouTube link-only sem motivo; só caminho oficial, sem terceiro, sem scraping, token nunca em log; board intocado.
- [ ] Invariantes AGENTS/engineering-standards: Local API com `user` + `overrideAccess: false` (bypass só com comentário já justificado no pipeline); writes novos seguem um único dono (job/attach com transação onde há multi-write; token via raw SQL do owner); Consent sem chave nova; identificadores em inglês/copy pt-BR; `lib/` não importa `utilities/`; migration nova (nada retroativo) + `pnpm migrate` + `generate:types`; `curatedFields` intocado; `sourceUrl` canônico como identidade.
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam; e2e do motivo (ficha mostra, lista não); pins de teste atualizados.
- [ ] `pnpm gate:fast` verde; `pnpm test:int` verde; e2e curado verde; `pnpm push` via GitHub.
## Self-score
**Self-score decision-quality: 4/5.** (1) Decisões caras têm `Opções/Recomendação/Rejeitadas` explícitas (campo/migration, janela/paginação, matching, semântica de falha, refresh, ciclo de vida, UI); (2) cabe no appetite herdado — reusa pipeline C211, seam injetável do resolver, helpers do owner do feed e shells da ficha; (3) rabbit holes nomeados (varredura, scraping, álbum, retry de peça-link, `paging.next`, time-based, painel novo, backfill); (4) depth check: paginação fica no dono do cliente Graph API, motivo no dono do vocabulário, persistência num único escritor, attach/retry reusam os escritores existentes — sem pass-through nem twin; (5) aceite de produto intacto. Não 5/5 por duas incertezas honestly assumidas: a forma real da paginação na API (C212 "a confirmar", mitigada por degradação honesta) e o mapeamento de `!mediaUrl` para `indisponivel` (micro-decisão reversível).

View file

@ -18,7 +18,11 @@ import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageC
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { CAMPAIGN_COMMUNICATION_CONTEUDOS } from '@/lib/campaignPaths'
import { contentPieceOriginLabels, contentPieceTopicLabel } from '@/lib/contentPiece'
import {
CONTENT_PIECE_LINK_LABEL,
contentPieceOriginLabels,
contentPieceTopicLabel,
} from '@/lib/contentPiece'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
ContentPieceNotFoundError,
@ -58,6 +62,38 @@ export default async function ContentPieceDetailPage({ params }: ContentPieceDet
])
const { piece } = data
// C220 — while the official extraction runs, the ficha shows the three
// designed steps (scene 1): the link arrived, the official media is being
// looked up (or was found), and the cataloguing follows.
const mediaStepDone = piece.step !== null && piece.step !== 'extraindo'
const processingSteps =
piece.origin === 'instagram' && piece.processingStatus === 'processando' && !piece.hasFile
? [
{
label: '1. Link recebido',
stateLabel: 'Concluído',
className: 'rounded-lg border border-green-200 bg-green-50 p-3',
stateClassName: 'text-green-800',
},
{
label: '2. Mídia oficial',
stateLabel: mediaStepDone ? 'Concluído' : 'Em andamento',
className: mediaStepDone
? 'rounded-lg border border-green-200 bg-green-50 p-3'
: 'rounded-lg border border-amber-200 bg-amber-50 p-3',
stateClassName: mediaStepDone ? 'text-green-800' : 'text-amber-900',
},
{
label: '3. Catalogação',
stateLabel: mediaStepDone ? 'Em andamento' : 'Aguardando',
className: mediaStepDone
? 'rounded-lg border border-amber-200 bg-amber-50 p-3'
: 'rounded-lg border bg-muted p-3 text-muted-foreground',
stateClassName: mediaStepDone ? 'text-amber-900' : '',
},
]
: null
return (
<CampaignPageShell aria-label="Peça da Central de Conteúdos">
<SetCampaignPageChrome chrome={{ title: piece.title }} />
@ -117,7 +153,11 @@ export default async function ContentPieceDetailPage({ params }: ContentPieceDet
/>
) : (
<div className="grid aspect-video place-items-center p-4 text-center text-xs font-semibold text-muted-foreground">
{piece.origin === 'arquivo' ? 'Sem arquivo' : 'Peça-link'}
{piece.origin === 'arquivo'
? 'Sem arquivo'
: piece.processingStatus === 'processando'
? 'Buscando a mídia no perfil oficial…'
: CONTENT_PIECE_LINK_LABEL}
</div>
)}
</div>
@ -162,7 +202,20 @@ export default async function ContentPieceDetailPage({ params }: ContentPieceDet
) : null}
</dl>
{!piece.hasFile ? (
{!piece.hasFile && piece.linkFailureReasonLabel ? (
<div className="mt-5 rounded-lg border border-amber-200 bg-amber-50 p-4">
<p className="text-xs font-semibold text-amber-950">
Por que o arquivo não foi baixado
</p>
<p className="mt-2 text-sm font-medium text-amber-950">
{piece.linkFailureReasonLabel}
</p>
<p className="mt-2 text-xs leading-5 text-amber-900">
A peça continua disponível pelo link.
</p>
<ContentPieceAttachFileButton contentPieceId={piece.id} className="mt-3" />
</div>
) : !piece.hasFile && piece.processingStatus !== 'processando' ? (
<div className="mt-5 rounded-lg bg-muted p-3">
<p className="text-xs leading-5 text-muted-foreground">
Sem mídia oficial para extrair. A peça circula pelo link; se quiser arquivar o
@ -181,18 +234,53 @@ export default async function ContentPieceDetailPage({ params }: ContentPieceDet
<ContentPieceRetryButton contentPieceId={piece.id} className="mt-2" />
</div>
) : piece.processingStatus === 'processando' ? (
<div className="mt-5 rounded-lg bg-muted p-3">
<p className="text-xs leading-5 text-muted-foreground">
{piece.step === 'transcrevendo'
? 'Transcrevendo e catalogando…'
: 'Preparando a peça…'}{' '}
A ficha fica disponível para revisão assim que terminar.
</p>
</div>
piece.origin === 'instagram' && !piece.hasFile ? (
<div className="mt-5 rounded-lg bg-muted p-3">
<div className="h-2 overflow-hidden rounded-full bg-background">
<div className="h-full w-1/2 animate-pulse rounded-full bg-primary motion-reduce:animate-none" />
</div>
<p className="mt-2 text-xs leading-5 text-muted-foreground">
Procurando a publicação no perfil oficial…
</p>
</div>
) : (
<div className="mt-5 rounded-lg bg-muted p-3">
<p className="text-xs leading-5 text-muted-foreground">
{piece.step === 'transcrevendo'
? 'Transcrevendo e catalogando…'
: 'Preparando a peça…'}{' '}
A ficha fica disponível para revisão assim que terminar.
</p>
</div>
)
) : null}
</div>
<div>
{processingSteps ? (
// The detailed tracker is the desktop scene; mobile keeps the
// compact progress box in the media column (design scene 3).
<div className="mb-6 hidden rounded-xl border p-5 md:block">
<p className="text-sm font-semibold">Processamento da peça</p>
<ol className="mt-4 grid grid-cols-3 gap-3 text-xs">
{processingSteps.map((step) => (
<li key={step.label} className={step.className}>
<b>{step.label}</b>
<p className={`mt-1 ${step.stateClassName}`}>{step.stateLabel}</p>
</li>
))}
</ol>
</div>
) : piece.origin !== 'arquivo' && piece.processingStatus === 'pronto' && piece.hasFile ? (
<div className="mb-6 rounded-xl border border-green-200 bg-green-50 p-4">
<p className="text-sm font-semibold text-green-900">
{piece.transcript ? 'Arquivo e transcrição prontos' : 'Arquivo pronto'}
</p>
<p className="mt-1 text-xs leading-5 text-green-900/80">
A ficha foi catalogada. Revise os campos antes de publicar.
</p>
</div>
) : null}
<ContentPieceCirculationPanel
circulation={piece.circulation}
isPublished={piece.isPublished}

View file

@ -54,6 +54,7 @@ const contentPieceSelect = {
pieceDate: true,
publishedAt: true,
error: true,
linkFailureReason: true,
media: true,
curatedFields: true,
} as const
@ -187,7 +188,12 @@ export const retryContentPieceForActor = async (input: {
where: {
and: [{ id: { equals: parsed.contentPieceId } }, { processingStatus: { equals: 'falhou' } }],
},
data: { processingStatus: 'processando', step: 'extraindo', error: null },
data: {
processingStatus: 'processando',
step: 'extraindo',
error: null,
linkFailureReason: null,
},
depth: 0,
select: contentPieceSelect,
user: actor,

View file

@ -10,6 +10,7 @@ import {
CONTENT_PIECE_CURATED_FIELDS,
CONTENT_PIECE_DESCRIPTION_MAX_LENGTH,
CONTENT_PIECE_INSTITUTION_MAX_LENGTH,
CONTENT_PIECE_LINK_FAILURE_REASONS,
CONTENT_PIECE_ORIGINS,
CONTENT_PIECE_PROCESSING_STATUSES,
CONTENT_PIECE_STATUSES,
@ -17,6 +18,7 @@ import {
CONTENT_PIECE_TITLE_MAX_LENGTH,
CONTENT_PIECE_TYPES,
contentPieceCuratedFieldLabels,
contentPieceLinkFailureReasonLabels,
contentPieceOriginLabels,
contentPieceProcessingStatusLabels,
contentPieceSearchText,
@ -68,6 +70,10 @@ const ORIGIN_OPTIONS = CONTENT_PIECE_ORIGINS.map((value) => ({
value,
label: contentPieceOriginLabels[value],
}))
const LINK_FAILURE_REASON_OPTIONS = CONTENT_PIECE_LINK_FAILURE_REASONS.map((value) => ({
value,
label: contentPieceLinkFailureReasonLabels[value],
}))
/**
* The one seam with the public Central (S27): every write of a piece — the
@ -403,6 +409,17 @@ export const ContentPiece: CollectionConfig = {
description: 'Motivo interno da falha; nunca vai à pessoa com o detalhe cru.',
},
},
{
name: 'linkFailureReason',
type: 'select',
label: 'Motivo da peça-link',
options: LINK_FAILURE_REASON_OPTIONS,
admin: {
readOnly: true,
description:
'Por que uma peça do Instagram ficou só como link; preenchido pelo processamento.',
},
},
{
name: 'searchText',
type: 'textarea',

View file

@ -7,14 +7,16 @@ import {
ContentPiecePublicationBadge,
} from '@/components/campaign/content/ContentPieceStatusBadge'
import { Button } from '@/components/ui/button'
import { contentPieceStepLabels } from '@/lib/contentPiece'
import { CONTENT_PIECE_LINK_LABEL, contentPieceStepLabels } from '@/lib/contentPiece'
import type { ContentPieceRowViewModel } from '@/lib/contentPieceCirculation'
/**
* C211 — the Central list on mobile (approved design scenes 5): one card per
* piece with the title + processing state, the type/publication line and the
* single "Abrir" (or "Reprocessar" when it failed). C213 adds the piece's
* circulation counters (2×2 grid) right above the action.
* C211/C220 — the Central list on mobile (approved design scenes 5): one card
* per piece with the title + processing state, the type/publication line and
* the single "Abrir" (or "Reprocessar" when it failed). A link piece also
* carries the neutral "Peça-link" label — the honest reason lives on the ficha,
* never on the list (C220 D7). C213 adds the piece's circulation counters (2×2
* grid) right above the action.
*/
export const ContentPieceCardList = ({
rows,
@ -25,49 +27,60 @@ export const ContentPieceCardList = ({
}) => (
<div className="flex flex-col gap-3 md:hidden">
{rows.length === 0 && empty ? empty : null}
{rows.map((piece) => (
<article key={piece.id} className="rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<Link href={piece.detailHref} className="min-w-0 text-sm font-semibold hover:underline">
{piece.title}
</Link>
<ContentPieceProcessingBadge status={piece.processingStatus} className="shrink-0" />
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ContentPiecePublicationBadge status={piece.status} />
<span>
{[piece.typeLabel, piece.durationLabel].filter(Boolean).join(' · ')}
{piece.cityLabel ? ` · ${piece.cityLabel}` : ''}
</span>
</div>
{rows.map((piece) => {
// A peça-link carries no honest type (the link may be a carousel) — the
// neutral label replaces it (C220 D7, design scene 3).
const isLinkOnly =
piece.origin !== 'arquivo' && piece.processingStatus === 'pronto' && !piece.hasFile
const meta = [
piece.origin !== 'arquivo' ? piece.originLabel : null,
isLinkOnly ? CONTENT_PIECE_LINK_LABEL : null,
...(isLinkOnly ? [] : [piece.typeLabel, piece.durationLabel]),
]
return (
<article key={piece.id} className="rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<Link href={piece.detailHref} className="min-w-0 text-sm font-semibold hover:underline">
{piece.title}
</Link>
<ContentPieceProcessingBadge status={piece.processingStatus} className="shrink-0" />
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ContentPiecePublicationBadge status={piece.status} />
<span>
{meta.filter(Boolean).join(' · ')}
{piece.cityLabel ? ` · ${piece.cityLabel}` : ''}
</span>
</div>
{piece.processingStatus === 'processando' ? (
<>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full w-1/2 animate-pulse rounded-full bg-primary motion-reduce:animate-none" />
</div>
<p className="mt-2 text-xs text-muted-foreground">
{piece.step ? contentPieceStepLabels[piece.step] : 'Processando…'}
</p>
</>
) : piece.processingStatus === 'falhou' && piece.failureMessage ? (
<p className="mt-2 text-xs text-red-800">{piece.failureMessage}</p>
) : null}
{piece.processingStatus === 'processando' ? (
<>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full w-1/2 animate-pulse rounded-full bg-primary motion-reduce:animate-none" />
</div>
<p className="mt-2 text-xs text-muted-foreground">
{piece.step ? contentPieceStepLabels[piece.step] : 'Processando…'}
</p>
</>
) : piece.processingStatus === 'falhou' && piece.failureMessage ? (
<p className="mt-2 text-xs text-red-800">{piece.failureMessage}</p>
) : null}
<ContentPieceCirculationCounters
circulation={piece.circulation}
isPublished={piece.isPublished}
layout="card"
/>
<ContentPieceCirculationCounters
circulation={piece.circulation}
isPublished={piece.isPublished}
layout="card"
/>
{piece.canRetry ? (
<ContentPieceRetryButton contentPieceId={piece.id} className="mt-3" />
) : (
<Button asChild variant="outline" className="mt-3 min-h-11 w-full">
<Link href={piece.detailHref}>Abrir</Link>
</Button>
)}
</article>
))}
{piece.canRetry ? (
<ContentPieceRetryButton contentPieceId={piece.id} className="mt-3" />
) : (
<Button asChild variant="outline" className="mt-3 min-h-11 w-full">
<Link href={piece.detailHref}>Abrir</Link>
</Button>
)}
</article>
)
})}
</div>
)

View file

@ -335,6 +335,36 @@ export const contentPieceSearchText = (input: ContentPieceSearchInput): string =
.join(' '),
)
/**
* C220 — why a piece added by link stayed a peça-link. Closed vocabulary in
* product language (the UI only maps the label), persisted by the pipeline on
* a `pronto` row: a peça-link is a legitimate outcome, never a failure. The
* labels are the gate's verbatim literals.
*/
export const CONTENT_PIECE_LINK_FAILURE_REASONS = [
'nao-encontrado',
'carrossel',
'indisponivel',
'sem-credencial',
] as const
export type ContentPieceLinkFailureReason = (typeof CONTENT_PIECE_LINK_FAILURE_REASONS)[number]
export const contentPieceLinkFailureReasonLabels: Record<ContentPieceLinkFailureReason, string> = {
'nao-encontrado': 'Link não encontrado entre as mídias recentes do perfil',
carrossel: 'Carrossel: sem arquivo único para baixar',
indisponivel: 'Instagram indisponível no momento',
'sem-credencial': 'Sem credencial do Instagram configurada',
}
export const isContentPieceLinkFailureReason = (
value: unknown,
): value is ContentPieceLinkFailureReason =>
CONTENT_PIECE_LINK_FAILURE_REASONS.includes(value as ContentPieceLinkFailureReason)
/** The honest name of a piece that circulates only by its platform link. */
export const CONTENT_PIECE_LINK_LABEL = 'Peça-link'
type ContentPieceInstagramLink = {
origin: 'instagram'
canonicalUrl: string
@ -354,8 +384,10 @@ const INSTAGRAM_KINDS = ['p', 'reel', 'reels', 'tv']
/**
* Normalizes an Instagram or YouTube link into its canonical form, dropping
* tracking parameters and hash. The shortcode/video id is the identity, so two
* pastes of the same post collapse into one `sourceUrl` (unique in the DB).
* Returns null for any other host — the piece only accepts the two platforms.
* pastes of the same post collapse into one `sourceUrl` (unique in the DB) —
* including the profile-prefixed spelling the browser copies from the grid
* (`/<profile>/reel/<shortcode>/`). Returns null for any other host — the
* piece only accepts the two platforms.
*/
export const parseContentPieceLink = (raw: string | null | undefined): ContentPieceLink | null => {
const trimmed = (raw ?? '').trim()
@ -373,7 +405,13 @@ export const parseContentPieceLink = (raw: string | null | undefined): ContentPi
const segments = url.pathname.split('/').filter(Boolean)
if (host === 'instagram.com') {
const [kind, shortcode] = segments
// Two accepted spellings: the canonical `/<kind>/<shortcode>/` and the one
// the browser copies from the profile, `/<profile>/<kind>/<shortcode>/`.
const [first, second, third] = segments
const prefixed = segments.length === 3
if (prefixed && (first ?? '').toLowerCase() === 'stories') return null
const kind = prefixed ? second : first
const shortcode = prefixed ? third : second
if (!kind || !shortcode || !INSTAGRAM_KINDS.includes(kind.toLowerCase())) return null
const safeShortcode = shortcode.replace(/[^A-Za-z0-9_-]/g, '')
if (!safeShortcode) return null
@ -434,6 +472,8 @@ export type ContentPieceViewModel = {
pieceDateLabel: string | null
publishedAtLabel: string | null
failureMessage: string | null
linkFailureReason: ContentPieceLinkFailureReason | null
linkFailureReasonLabel: string | null
canRetry: boolean
isPublished: boolean
hasFile: boolean
@ -457,6 +497,7 @@ type ContentPieceViewRecord = {
pieceDate?: string | null
publishedAt?: string | null
error?: string | null
linkFailureReason?: string | null
media?: number | { id: number } | null
}
@ -495,6 +536,9 @@ export const toContentPieceViewModel = (record: ContentPieceViewRecord): Content
const status = normalizedStatus(record.status)
const origin = normalizedOrigin(record.origin)
const hasFile = mediaIdOf(record.media) !== null
const linkFailureReason = isContentPieceLinkFailureReason(record.linkFailureReason)
? record.linkFailureReason
: null
return {
id: record.id,
@ -515,6 +559,10 @@ export const toContentPieceViewModel = (record: ContentPieceViewRecord): Content
pieceDateLabel: dateLabelOf(record.pieceDate),
publishedAtLabel: dateLabelOf(record.publishedAt),
failureMessage: contentPieceFailureMessage({ error: record.error, step }),
linkFailureReason,
linkFailureReasonLabel: linkFailureReason
? contentPieceLinkFailureReasonLabels[linkFailureReason]
: null,
canRetry: canRetryContentPiece(processingStatus),
isPublished: status === 'publicado',
hasFile,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_content_piece_link_failure_reason" AS ENUM('nao-encontrado', 'carrossel', 'indisponivel', 'sem-credencial');
ALTER TABLE "content_piece" ADD COLUMN "link_failure_reason" "enum_content_piece_link_failure_reason";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "content_piece" DROP COLUMN "link_failure_reason";
DROP TYPE "public"."enum_content_piece_link_failure_reason";`)
}

View file

@ -83,6 +83,7 @@ import * as migration_20260923_054145_add_share_link_announcement from './202609
import * as migration_20260923_172437_add_content_events from './20260923_172437_add_content_events';
import * as migration_20260923_190414_add_content_event_variant from './20260923_190414_add_content_event_variant';
import * as migration_20260924_042028_add_web_speech_origin from './20260924_042028_add_web_speech_origin';
import * as migration_20260924_045625_add_content_piece_link_failure_reason from './20260924_045625_add_content_piece_link_failure_reason';
export const migrations = [
{
@ -510,4 +511,9 @@ export const migrations = [
down: migration_20260924_042028_add_web_speech_origin.down,
name: '20260924_042028_add_web_speech_origin'
},
{
up: migration_20260924_045625_add_content_piece_link_failure_reason.up,
down: migration_20260924_045625_add_content_piece_link_failure_reason.down,
name: '20260924_045625_add_content_piece_link_failure_reason'
},
];

View file

@ -1298,6 +1298,10 @@ export interface ContentPiece {
* Motivo interno da falha; nunca vai à pessoa com o detalhe cru.
*/
error?: string | null;
/**
* Por que uma peça do Instagram ficou só como link; preenchido pelo processamento.
*/
linkFailureReason?: ('nao-encontrado' | 'carrossel' | 'indisponivel' | 'sem-credencial') | null;
/**
* Título, descrição, transcrição, temas, cidade e instituição normalizados (sem acentos, minúsculas).
*/
@ -2824,6 +2828,7 @@ export interface ContentPieceSelect<T extends boolean = true> {
processingStatus?: T;
step?: T;
error?: T;
linkFailureReason?: T;
searchText?: T;
curatedFields?: T;
createdBy?: T;

View file

@ -278,6 +278,7 @@ export const runContentPieceJob = async (
processingStatus: 'pronto',
step: null,
error: null,
linkFailureReason: source.linkFailureReason,
...(durationSeconds !== null ? { durationSeconds } : {}),
}

View file

@ -9,26 +9,29 @@ import {
CONTENT_MEDIA_SLUG,
CONTENT_PIECE_MAX_BYTES,
parseContentPieceLink,
type ContentPieceLinkFailureReason,
type ContentPieceType,
} from '@/lib/contentPiece'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import {
INSTAGRAM_MAX_RESULTS_CAP,
isInstagramFeedConfigured,
loadInstagramFeed,
persistInstagramAccessToken,
} from '@/utilities/socialFeed/instagramFeed'
/**
* C211 — the official-path resolution of a piece that entered by link. There is
* NO scraping and no oEmbed: the only extraction route is the Instagram Graph
* API of the campaign's OWN account (listing the profile media and matching the
* pasted permalink), exactly what the gate decided. YouTube exposes only
* metadata through the Data API, so a YouTube piece always circulates by link.
* C211/C220 — the official-path resolution of a piece that entered by link.
* There is NO scraping and no oEmbed: the only extraction route is the
* Instagram Graph API of the campaign's OWN account (listing the profile media
* and matching the pasted shortcode), exactly what the gate decided. YouTube
* exposes only metadata through the Data API, so a YouTube piece always
* circulates by link and carries NO failure reason.
*
* A failed extraction never throws at the caller: the piece becomes a
* peça-link (the job still catalogues the official caption when there is one).
* A download that starts and fails DOES throw — the row is preserved and the
* assessoria can reprocess or attach the original file.
* Every Instagram-side failure returns a `linkFailureReason` instead of
* throwing: the piece becomes a peça-link (`pronto`) with an honest reason on
* the ficha — never silence, never "Falhou". Only failures the pipeline itself
* can retry or the assessoria can solve by attaching the file (the 4 GB
* ceiling, storage, ffmpeg) still throw.
*/
export type ContentPieceSourceMedia = {
@ -47,10 +50,39 @@ export type ContentPieceSourceResolution = {
caption: string | null
/** The type the official media reveals (an Instagram image is a photo). */
suggestedType?: ContentPieceType
/** C220 — why the piece stayed a peça-link; null when there is nothing to explain. */
linkFailureReason: ContentPieceLinkFailureReason | null
}
type FeedLoader = typeof loadInstagramFeed
/**
* C220 — how far back the pasted post is searched in the official profile:
* 10 pages of 50, with an early stop on the match. The typical paste is the
* post that just went out (one page); the declared window covers ~1–2 years
* and stays well under the API edge's 10K budget (C212 §Q1). A post beyond it
* gets the honest `nao-encontrado` reason.
*/
export const CONTENT_PIECE_LINK_INSTAGRAM_WINDOW = 500
/** A hung Graph API must not hold the piece `processando` until the 1 h reaper. */
const CONTENT_PIECE_LINK_FEED_TIMEOUT_MS = 30_000
/** Same deadline for the media CDN (the speech pipeline precedent is 180 s). */
const CONTENT_PIECE_LINK_DOWNLOAD_TIMEOUT_MS = 3 * 60_000
/**
* Our own ceiling, not the platform's: it must stay distinguishable from a
* transport failure so the resolver rethrows it (`falhou`) instead of dressing
* a policy refusal as "Instagram indisponível".
*/
export class ContentPieceMediaTooLargeError extends Error {
constructor() {
super('A mídia extraída excede o limite da Central.')
this.name = 'ContentPieceMediaTooLargeError'
}
}
const streamResponseToFile = async ({
response,
destination,
@ -66,7 +98,7 @@ const streamResponseToFile = async ({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.length
if (received > maxBytes) {
callback(new Error('A mídia extraída excede o limite da Central.'))
callback(new ContentPieceMediaTooLargeError())
return
}
callback(null, chunk)
@ -82,7 +114,8 @@ const streamResponseToFile = async ({
/**
* Resolves the source of one piece: the archived media when it exists,
* otherwise the official extraction for an Instagram link, otherwise nothing
* (peça-link). Injectable deps keep the int test free of network calls.
* (peça-link). Injectable deps keep the int test free of network calls. Every
* Instagram-side miss carries a `linkFailureReason` for the ficha.
*/
export const resolveContentPieceSource = async ({
payload,
@ -103,26 +136,38 @@ export const resolveContentPieceSource = async ({
loadFeed?: FeedLoader
fetchImpl?: (input: string, init?: RequestInit) => Promise<Response>
}): Promise<ContentPieceSourceResolution> => {
/** A peça-link answer: no media, optional official caption, honest reason. */
const linkOnly = (
linkFailureReason: ContentPieceLinkFailureReason,
caption: string | null = null,
): ContentPieceSourceResolution => ({
media: null,
localPath: null,
caption,
linkFailureReason,
})
if (piece.media && typeof piece.media === 'object') {
return { media: piece.media, localPath: null, caption: null }
return { media: piece.media, localPath: null, caption: null, linkFailureReason: null }
}
if (piece.media) {
// A bare id (depth 0) is not a file the pipeline can read; fail closed to
// the link resolution instead of handing a number to the downloader.
return { media: null, localPath: null, caption: null }
return { media: null, localPath: null, caption: null, linkFailureReason: null }
}
if (piece.origin !== 'instagram' || !piece.sourceUrl) {
return { media: null, localPath: null, caption: null }
// YouTube (link-only by design) and uploaded files have nothing to explain.
return { media: null, localPath: null, caption: null, linkFailureReason: null }
}
const link = parseContentPieceLink(piece.sourceUrl)
if (!link || link.origin !== 'instagram') {
return { media: null, localPath: null, caption: null }
return { media: null, localPath: null, caption: null, linkFailureReason: null }
}
const settings = await payload.findGlobal({ slug: 'social-feed-settings', depth: 0 })
if (!isInstagramFeedConfigured(settings)) {
return { media: null, localPath: null, caption: null }
return linkOnly('sem-credencial')
}
let feed: Awaited<ReturnType<FeedLoader>>
@ -130,41 +175,71 @@ export const resolveContentPieceSource = async ({
feed = await loadFeed({
accessToken: settings.instagramAccessToken as string,
userId: settings.instagramUserId as string,
maxResults: INSTAGRAM_MAX_RESULTS_CAP,
maxResults: CONTENT_PIECE_LINK_INSTAGRAM_WINDOW,
fetchImpl,
signal: AbortSignal.timeout(CONTENT_PIECE_LINK_FEED_TIMEOUT_MS),
})
} catch {
// Token expired, API down: fail closed to the link piece, never to an error
// in the assessoria's face.
return { media: null, localPath: null, caption: null }
// Token expired, API down: the piece becomes a peça-link with the honest
// reason, never an error in the assessoria's face.
return linkOnly('indisponivel')
}
// Canonical URLs on both sides: the shortcode is the identity and the form is
// the same, so two spellings of the same post always match.
const matched = feed.posts.find(
(post) => parseContentPieceLink(post.permalink)?.canonicalUrl === link.canonicalUrl,
)
if (!matched) return { media: null, localPath: null, caption: null }
if (feed.refreshedAccessToken) {
// Best-effort: storing the refreshed token never fails the extraction, and
// the token itself is never logged.
await persistInstagramAccessToken(payload, feed.refreshedAccessToken).catch(() => undefined)
}
// The shortcode is the identity; the kind (`p` × `reel`) is presentation, so
// a `/p/ABC/` paste matches a reel's permalink too. Matching inside the OWN
// profile feed is also what guarantees a third-party link never downloads.
const matched = feed.posts.find((post) => {
const parsed = parseContentPieceLink(post.permalink)
return parsed?.origin === 'instagram' && parsed.shortcode === link.shortcode
})
if (!matched) {
return linkOnly('nao-encontrado')
}
if (matched.mediaType === 'CAROUSEL_ALBUM') {
return linkOnly('carrossel', matched.caption)
}
const extractable =
matched.mediaType === 'IMAGE' || matched.mediaType === 'VIDEO' || matched.mediaType === 'REEL'
if (!extractable || !matched.mediaUrl) {
// Carousel (no single file) or a post without a media URL: the caption is
// still official metadata the cataloguing can use.
return { media: null, localPath: null, caption: matched.caption }
// The API found the post but has no single file to hand over.
return linkOnly('indisponivel', matched.caption)
}
const response = await fetchImpl(matched.mediaUrl)
if (!response.ok) throw new Error('Não foi possível baixar a mídia do Instagram.')
let response: Response
try {
response = await fetchImpl(matched.mediaUrl, {
signal: AbortSignal.timeout(CONTENT_PIECE_LINK_DOWNLOAD_TIMEOUT_MS),
})
} catch {
return linkOnly('indisponivel', matched.caption)
}
if (!response.ok) {
return linkOnly('indisponivel', matched.caption)
}
const suggestedType: ContentPieceType = matched.mediaType === 'IMAGE' ? 'foto' : 'video'
const extension = suggestedType === 'foto' ? 'jpg' : 'mp4'
const localPath = `${tempDir}/instagram-${link.shortcode}.${extension}`
await streamResponseToFile({
response,
destination: localPath,
maxBytes: CONTENT_PIECE_MAX_BYTES,
})
try {
await streamResponseToFile({
response,
destination: localPath,
maxBytes: CONTENT_PIECE_MAX_BYTES,
})
} catch (error) {
// Our ceiling is not the platform's: it stays a failure the assessoria
// solves by attaching the file. Any other stream error is transport.
if (error instanceof ContentPieceMediaTooLargeError) throw error
return linkOnly('indisponivel', matched.caption)
}
const media = await withPayloadTransaction(payload, async ({ req }) => {
const created = await payload.create({
@ -189,5 +264,5 @@ export const resolveContentPieceSource = async ({
return created
})
return { media, localPath, caption: matched.caption, suggestedType }
return { media, localPath, caption: matched.caption, suggestedType, linkFailureReason: null }
}

View file

@ -53,6 +53,7 @@ const contentPieceDetailSelect = {
sourceUrl: true,
municipality: true,
curatedFields: true,
linkFailureReason: true,
} as const
export const ContentPieceNotFoundError = createEntityNotFoundError(

View file

@ -278,6 +278,7 @@ export const attachContentPieceMedia = async ({
processingStatus: processing ? 'processando' : 'pronto',
...(processing ? { step: 'extraindo' } : {}),
error: null,
linkFailureReason: null,
},
depth: 0,
user: actor,

View file

@ -7,6 +7,7 @@ import type { Payload } from 'payload'
const INSTAGRAM_API_BASE_URL = process.env.INSTAGRAM_API_BASE_URL ?? 'https://graph.instagram.com'
/** Page ceiling of one `/media` call: `maxResults` beyond it walks cursors. */
export const INSTAGRAM_MAX_RESULTS_CAP = 50
type InstagramMediaType = 'IMAGE' | 'VIDEO' | 'REEL' | 'CAROUSEL_ALBUM'
@ -138,14 +139,39 @@ export const pickInstagramThumbnail = (item: {
}
/**
* Parses a `/media` response into posts. Throws on a protocol-level violation
* (no `data` array); individual malformed items are skipped, but an item
* without an id, a timestamp or a permalink is dropped — it could not be
* placed in the recency merge nor opened on the platform. A null caption is
* kept: captionless posts (e.g. grid mosaics) still show with a fallback
* title so the assessoria can exclude them item by item.
* Hard ceiling of pages one feed load may walk (defensive): the Graph API
* returns `limit` items per page, so a 500-media window needs 10 — 20 covers
* it with room, and a pathological API that always answers a cursor cannot
* loop forever.
*/
export const parseInstagramMediaResponse = (json: unknown): InstagramPost[] => {
const INSTAGRAM_MAX_MEDIA_PAGES = 20
export type InstagramMediaPage = {
posts: InstagramPost[]
/** Cursor of the next page; undefined when the API did not offer one. */
nextCursor?: string
}
const nextCursorFrom = (json: unknown): string | undefined => {
const paging = (json as { paging?: unknown }).paging
if (!paging || typeof paging !== 'object') return undefined
const cursors = (paging as { cursors?: unknown }).cursors
if (!cursors || typeof cursors !== 'object') return undefined
const after = (cursors as { after?: unknown }).after
return typeof after === 'string' && after ? after : undefined
}
/**
* Parses one `/media` page into posts plus the cursor of the next page. Throws
* on a protocol-level violation (no `data` array); individual malformed items
* are skipped, but an item without an id, a timestamp or a permalink is
* dropped — it could not be placed in the recency merge nor opened on the
* platform. A null caption is kept: captionless posts (e.g. grid mosaics)
* still show with a fallback title so the assessoria can exclude them item by
* item. Only the cursor is read from `paging`; `paging.next` (which carries
* the access token in its URL) is deliberately ignored.
*/
export const parseInstagramMediaPage = (json: unknown): InstagramMediaPage => {
if (!json || typeof json !== 'object') {
throw new Error('Resposta de mídia do Instagram inválida')
}
@ -181,9 +207,13 @@ export const parseInstagramMediaResponse = (json: unknown): InstagramPost[] => {
thumbnailUrl: pickInstagramThumbnail(rawItem),
})
}
return posts
return { posts, nextCursor: nextCursorFrom(json) }
}
/** Posts of one `/media` page — the one-page convenience for non-paginating readers. */
export const parseInstagramMediaResponse = (json: unknown): InstagramPost[] =>
parseInstagramMediaPage(json).posts
/**
* Keeps only the posts whose ids are not excluded, in API order (newest
* first), capped at `maxItems`. The board skips an excluded post — it never
@ -200,11 +230,15 @@ const jsonFrom = async (response: Response): Promise<unknown> => response.json()
/**
* Fetches the username and the latest media of a Business/Creator profile:
* `GET /{userId}` for the username, then `GET /{userId}/media` for the posts
* (permalink, caption, thumbnail, timestamp). On any failure it attempts one
* token refresh (`refresh_access_token` — only mints/refreshes Instagram
* Login tokens; page tokens from Facebook Login error out and the caller
* falls back to the snapshot) and retries once. Throws when the retry also
* fails so the cached wrapper can fail closed.
* (permalink, caption, thumbnail, timestamp). `maxResults` is the TOTAL window
* of posts: up to `INSTAGRAM_MAX_RESULTS_CAP` per page, walking the
* `paging.cursors.after` cursor while the window is not filled — so a caller
* asking for 50 makes exactly one call (the board's contract is untouched) and
* a deeper window is an explicit opt-in. On any failure it attempts one token
* refresh (`refresh_access_token` — only mints/refreshes Instagram Login
* tokens; page tokens from Facebook Login error out and the caller falls back
* to the snapshot) and retries once. Throws when the retry also fails so the
* cached wrapper can fail closed.
*/
export const loadInstagramFeed = async ({
accessToken,
@ -223,17 +257,30 @@ export const loadInstagramFeed = async ({
const userJson = (await jsonFrom(userResponse)) as { username?: unknown }
const username = typeof userJson.username === 'string' ? userJson.username : null
const mediaParams = new URLSearchParams({
fields: MEDIA_FIELDS,
limit: String(Math.min(Math.max(maxResults, 1), INSTAGRAM_MAX_RESULTS_CAP)),
access_token: token,
})
const mediaResponse = await fetchImpl(`${baseUrl}/${userId}/media?${mediaParams}`, { signal })
if (!mediaResponse.ok) {
throw await apiErrorFrom(mediaResponse)
const pageSize = Math.min(Math.max(maxResults, 1), INSTAGRAM_MAX_RESULTS_CAP)
const posts: InstagramPost[] = []
let after: string | undefined
for (let page = 0; page < INSTAGRAM_MAX_MEDIA_PAGES; page += 1) {
const mediaParams = new URLSearchParams({
fields: MEDIA_FIELDS,
limit: String(pageSize),
access_token: token,
})
if (after) mediaParams.set('after', after)
const mediaResponse = await fetchImpl(`${baseUrl}/${userId}/media?${mediaParams}`, {
signal,
})
if (!mediaResponse.ok) {
throw await apiErrorFrom(mediaResponse)
}
const parsed = parseInstagramMediaPage(await jsonFrom(mediaResponse))
posts.push(...parsed.posts)
if (posts.length >= maxResults || !parsed.nextCursor) break
after = parsed.nextCursor
}
return { username, posts: parseInstagramMediaResponse(await jsonFrom(mediaResponse)) }
return { username, posts: posts.slice(0, maxResults) }
}
try {

View file

@ -1,5 +1,6 @@
import { request as playwrightRequest } from '@playwright/test'
import type { ContentPieceLinkFailureReason } from '@/lib/contentPiece'
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
import { normalizeForSearch } from '../../src/lib/speechSearch.js'
import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
@ -884,6 +885,7 @@ test.describe('communication vertical (C154/C162)', () => {
origin?: 'arquivo' | 'instagram' | 'youtube'
withMedia?: boolean
sourceUrl?: string
linkFailureReason?: ContentPieceLinkFailureReason
},
) => {
const bytes = Buffer.from(`content-piece-${input.marker}`)
@ -910,6 +912,7 @@ test.describe('communication vertical (C154/C162)', () => {
origin: input.origin ?? 'arquivo',
...(media ? { media: media.id } : {}),
...(input.sourceUrl ? { sourceUrl: input.sourceUrl } : {}),
...(input.linkFailureReason ? { linkFailureReason: input.linkFailureReason } : {}),
},
depth: 0,
})
@ -1197,5 +1200,37 @@ test.describe('communication vertical (C154/C162)', () => {
'Cole um link do Instagram ou do YouTube',
)
})
test('the peça-link ficha shows the honest reason and the list stays clean (C220)', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('pecamotivo')
const { piece } = await createPiece(campaign, {
marker,
origin: 'instagram',
withMedia: false,
sourceUrl: `https://www.instagram.com/reel/${marker}/`,
linkFailureReason: 'carrossel',
})
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const ficha = rendered(
await (await request.get(`/campanha/comunicacao/conteudos/${piece.id}`)).text(),
)
expect(ficha).toContain('Por que o arquivo não foi baixado')
expect(ficha).toContain('Carrossel: sem arquivo único para baixar')
expect(ficha).toContain('A peça continua disponível pelo link.')
expect(ficha).toContain('Anexar arquivo original')
// The list carries the neutral label and never the reason (D7).
const list = rendered(
await (await request.get(`/campanha/comunicacao/conteudos?q=${marker}`)).text(),
)
expect(list).toContain('Peça-link')
expect(list).not.toContain('Carrossel: sem arquivo único para baixar')
})
})
})

View file

@ -1,5 +1,8 @@
// @vitest-environment node
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Payload } from 'payload'
@ -49,6 +52,11 @@ import {
reapStaleContentPiece,
runContentPieceJob,
} from '@/utilities/content/contentPieceJob'
import {
CONTENT_PIECE_LINK_INSTAGRAM_WINDOW,
ContentPieceMediaTooLargeError,
resolveContentPieceSource,
} from '@/utilities/content/contentPieceLink'
import { loadContentPieceListPageData } from '@/utilities/content/contentPiecePageData'
import {
getPublishedContentPieceBySlug,
@ -60,6 +68,7 @@ import {
attachContentPieceMedia,
receiveContentPieceUpload,
} from '@/utilities/content/contentPieceUpload'
import type { InstagramPost } from '@/utilities/socialFeed/instagramFeed'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
@ -201,6 +210,48 @@ const catalogStub = async () => ({
source: 'ai' as const,
})
const instagramPost = (overrides: Partial<InstagramPost> = {}): InstagramPost => ({
id: 'media-1',
caption: 'Legenda oficial',
mediaType: 'REEL',
permalink: 'https://www.instagram.com/reel/ABC123/',
mediaUrl: 'https://cdn.example/reel.mp4',
timestamp: '2026-09-01T10:00:00+00:00',
...overrides,
})
/**
* C220 — the resolver reads the global for credentials; saving it triggers the
* Instagram sync hook, so the tests stub `fetch` to keep the hook off the
* network (the hook swallows the failure).
*/
const setInstagramSettings = async (configured: boolean): Promise<void> => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
try {
await payload.updateGlobal({
slug: 'social-feed-settings',
data: {
enabled: true,
instagramEnabled: configured,
instagramAccessToken: configured ? 'test-token' : '',
instagramUserId: configured ? '17841400000000000' : '',
},
overrideAccess: true,
})
} finally {
fetchSpy.mockRestore()
}
}
const withTempDir = async <T>(run: (tempDir: string) => Promise<T>): Promise<T> => {
const tempDir = await mkdtemp(join(tmpdir(), 'content-piece-link-'))
try {
return await run(tempDir)
} finally {
await rm(tempDir, { recursive: true, force: true })
}
}
describe('content pieces (C211)', () => {
let communicator: CampaignUser
let coordinator: CampaignUser
@ -741,6 +792,343 @@ describe('content pieces (C211)', () => {
})
expect(resolved.processingStatus).toBe('pronto')
expect(resolved.media).toBeNull()
// YouTube is link-only by design: no reason to explain (C220).
expect(resolved.linkFailureReason).toBeNull()
})
describe('C220 — link resolution and the honest peça-link reason', () => {
const pieceInput = (piece: {
id: number
title: string
origin: string
sourceUrl?: string | null
}) => ({
id: piece.id,
title: piece.title,
origin: piece.origin,
sourceUrl: piece.sourceUrl,
media: null,
})
it('resolves by shortcode, downloads the media and reports no reason', async () => {
await setInstagramSettings(true)
const { piece } = await createPiece({
title: 'Reel do perfil',
origin: 'instagram',
withMedia: false,
sourceUrl: 'https://www.instagram.com/depjorgesolla/reel/ABC123/',
})
let receivedMaxResults: number | null = null
const resolution = await withTempDir((tempDir) =>
resolveContentPieceSource({
payload,
piece: pieceInput(piece),
tempDir,
loadFeed: async (args) => {
receivedMaxResults = args.maxResults
return { username: 'depjorgesolla', posts: [instagramPost()] }
},
fetchImpl: async () => new Response(Buffer.from('reel-bytes'), { status: 200 }),
}),
)
// The declared window is what the resolver asks the feed for (C220 D2).
expect(receivedMaxResults).toBe(CONTENT_PIECE_LINK_INSTAGRAM_WINDOW)
expect(resolution.linkFailureReason).toBeNull()
expect(resolution.suggestedType).toBe('video')
expect(resolution.media).toBeTruthy()
if (resolution.media) createdMediaIds.add(resolution.media.id)
const row = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
overrideAccess: true,
})
expect(row.media).toBe(resolution.media?.id)
})
it('reports nao-encontrado when the post is not in the window', async () => {
await setInstagramSettings(true)
const { piece } = await createPiece({
origin: 'instagram',
withMedia: false,
sourceUrl: 'https://www.instagram.com/reel/FORA1/',
})
const resolution = await withTempDir((tempDir) =>
resolveContentPieceSource({
payload,
piece: pieceInput(piece),
tempDir,
loadFeed: async () => ({ username: 'depjorgesolla', posts: [instagramPost()] }),
fetchImpl: async () => new Response(null, { status: 200 }),
}),
)
expect(resolution.media).toBeNull()
expect(resolution.linkFailureReason).toBe('nao-encontrado')
})
it('reports carrossel for a carousel and indisponivel for a post without a file', async () => {
await setInstagramSettings(true)
const { piece } = await createPiece({
origin: 'instagram',
withMedia: false,
sourceUrl: 'https://www.instagram.com/reel/CARROSSEL1/',
})
const carousel = await withTempDir((tempDir) =>
resolveContentPieceSource({
payload,
piece: pieceInput(piece),
tempDir,
loadFeed: async () => ({
username: 'depjorgesolla',
posts: [
instagramPost({
permalink: 'https://www.instagram.com/reel/CARROSSEL1/',
mediaType: 'CAROUSEL_ALBUM',
mediaUrl: null,
}),
],
}),
fetchImpl: async () => new Response(null, { status: 200 }),
}),
)
expect(carousel.linkFailureReason).toBe('carrossel')
const noFile = await withTempDir((tempDir) =>
resolveContentPieceSource({
payload,
piece: pieceInput(piece),
tempDir,
loadFeed: async () => ({
username: 'depjorgesolla',
posts: [
instagramPost({
permalink: 'https://www.instagram.com/reel/CARROSSEL1/',
mediaType: 'IMAGE',
mediaUrl: null,
}),
],
}),
fetchImpl: async () => new Response(null, { status: 200 }),
}),
)
expect(noFile.linkFailureReason).toBe('indisponivel')
})
it('reports indisponivel when the feed or the download fails', async () => {
await setInstagramSettings(true)
const { piece } = await createPiece({
origin: 'instagram',
withMedia: false,
sourceUrl: 'https://www.instagram.com/reel/INDISPONIVEL1/',
})
const feedDown = await withTempDir((tempDir) =>
resolveContentPieceSource({
payload,
piece: pieceInput(piece),
tempDir,
loadFeed: async () => {
throw new Error('API down')
},
fetchImpl: async () => new Response(null, { status: 200 }),
}),
)
expect(feedDown.linkFailureReason).toBe('indisponivel')
const downloadDown = await withTempDir((tempDir) =>
resolveContentPieceSource({
payload,
piece: pieceInput(piece),
tempDir,
loadFeed: async () => ({
username: 'depjorgesolla',
posts: [instagramPost({ permalink: 'https://www.instagram.com/reel/INDISPONIVEL1/' })],
}),
fetchImpl: async () => new Response('nope', { status: 500 }),
}),
)
expect(downloadDown.linkFailureReason).toBe('indisponivel')
})
it('reports sem-credencial without the configured credential', async () => {
await setInstagramSettings(false)
const { piece } = await createPiece({
origin: 'instagram',
withMedia: false,
sourceUrl: 'https://www.instagram.com/reel/SEMCREDENCIAL1/',
})
const resolution = await withTempDir((tempDir) =>
resolveContentPieceSource({
payload,
piece: pieceInput(piece),
tempDir,
loadFeed: async () => {
throw new Error('must not be called')
},
fetchImpl: async () => new Response(null, { status: 200 }),
}),
)
expect(resolution.linkFailureReason).toBe('sem-credencial')
})
it('keeps our 4 GB ceiling as a failure and maps a broken stream to indisponivel', async () => {
await setInstagramSettings(true)
const { piece } = await createPiece({
origin: 'instagram',
withMedia: false,
sourceUrl: 'https://www.instagram.com/reel/TETO1/',
})
const matchingFeed = async () => ({
username: 'depjorgesolla',
posts: [instagramPost({ permalink: 'https://www.instagram.com/reel/TETO1/' })],
})
const streamError = (error: Error) =>
new Response(
new ReadableStream({
start(controller) {
controller.error(error)
},
}),
{ status: 200 },
)
await expect(
withTempDir((tempDir) =>
resolveContentPieceSource({
payload,
piece: pieceInput(piece),
tempDir,
loadFeed: matchingFeed,
fetchImpl: async () => streamError(new ContentPieceMediaTooLargeError()),
}),
),
).rejects.toBeInstanceOf(ContentPieceMediaTooLargeError)
const broken = await withTempDir((tempDir) =>
resolveContentPieceSource({
payload,
piece: pieceInput(piece),
tempDir,
loadFeed: matchingFeed,
fetchImpl: async () => streamError(new Error('socket reset')),
}),
)
expect(broken.linkFailureReason).toBe('indisponivel')
})
it('persists the reason from the job and clears it on attach and retry', async () => {
const { piece } = await createPiece({
title: 'Peça com motivo',
type: 'foto',
processingStatus: 'processando',
origin: 'instagram',
withMedia: false,
sourceUrl: 'https://www.instagram.com/reel/MOTIVO1/',
})
await runContentPieceJob(payload, piece.id, {
catalog: catalogStub,
resolveSource: async () => ({
media: null,
localPath: null,
caption: null,
linkFailureReason: 'carrossel',
}),
})
const row = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
overrideAccess: true,
})
expect(row.processingStatus).toBe('pronto')
expect(row.linkFailureReason).toBe('carrossel')
expect(row.error).toBeNull()
// Attaching the original clears the reason (the file is the resolution).
getCampaignUserMock.mockResolvedValue(communicator)
const bytes = Buffer.from('original-bytes')
await attachContentPieceMedia({
payload,
actor: communicator,
piece: { id: piece.id, title: piece.title, type: 'foto', media: null },
filename: 'original.jpg',
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
}),
contentLength: bytes.length,
startJob: () => undefined,
})
const attached = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
overrideAccess: true,
})
expect(attached.linkFailureReason).toBeNull()
expect(attached.media).toBeTruthy()
if (typeof attached.media === 'number') createdMediaIds.add(attached.media)
// A retry on a failed piece also clears a stale reason.
await payload.update({
collection: 'contentPiece',
id: piece.id,
data: {
processingStatus: 'falhou',
step: 'extraindo',
error: 'boom',
linkFailureReason: 'indisponivel',
},
overrideAccess: true,
})
const retried = await retryContentPieceForActor({ contentPieceId: piece.id })
expect(retried.processingStatus).toBe('processando')
expect(retried.linkFailureReason).toBeNull()
})
it('keeps the reason null when the resolution brings the media', async () => {
const { piece } = await createPiece({
type: 'foto',
processingStatus: 'processando',
origin: 'instagram',
withMedia: false,
sourceUrl: 'https://www.instagram.com/reel/MEDIA1/',
})
const media = await createMedia('resolvida.mp4')
await runContentPieceJob(payload, piece.id, {
catalog: catalogStub,
resolveSource: async () => ({
media,
localPath: null,
caption: 'Legenda',
linkFailureReason: null,
}),
})
const row = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
overrideAccess: true,
})
expect(row.processingStatus).toBe('pronto')
expect(row.linkFailureReason).toBeNull()
})
})
it('refuses a duplicate link and an invalid one', async () => {

View file

@ -6,6 +6,7 @@ import {
canRetryContentPiece,
contentPieceFailureMessage,
contentPieceIsPublic,
contentPieceLinkFailureReasonLabels,
contentPieceLinkTitle,
contentPieceOriginFromLink,
contentPieceSearchText,
@ -14,6 +15,7 @@ import {
contentPieceTitleFromFilename,
contentPieceTypeFromMime,
isContentPieceCuratedField,
isContentPieceLinkFailureReason,
isContentPieceOrigin,
isContentPieceProcessingStatus,
isContentPieceStatus,
@ -46,10 +48,22 @@ describe('content piece vocabulary', () => {
expect(isContentPieceCuratedField('slug')).toBe(false)
expect(isContentPieceOrigin('arquivo')).toBe(true)
expect(isContentPieceOrigin('link')).toBe(false)
expect(isContentPieceLinkFailureReason('carrossel')).toBe(true)
expect(isContentPieceLinkFailureReason('sem-credencial')).toBe(true)
expect(isContentPieceLinkFailureReason('falhou')).toBe(false)
expect(isContentPieceTopic('saude')).toBe(true)
expect(isContentPieceTopic('nao-existe')).toBe(false)
})
it('labels the four peça-link reasons verbatim (gate copy)', () => {
expect(contentPieceLinkFailureReasonLabels).toEqual({
'nao-encontrado': 'Link não encontrado entre as mídias recentes do perfil',
carrossel: 'Carrossel: sem arquivo único para baixar',
indisponivel: 'Instagram indisponível no momento',
'sem-credencial': 'Sem credencial do Instagram configurada',
})
})
it('knows which types need the pipeline', () => {
expect(needsContentPieceProcessing('video')).toBe(true)
expect(needsContentPieceProcessing('audio')).toBe(true)
@ -153,6 +167,31 @@ describe('content piece links', () => {
expect(parseContentPieceLink('https://m.instagram.com/tv/ABC123/')?.origin).toBe('instagram')
})
it('accepts the profile-prefixed spelling the browser copies', () => {
expect(parseContentPieceLink('https://www.instagram.com/depjorgesolla/reel/ABC123/')).toEqual({
origin: 'instagram',
shortcode: 'ABC123',
canonicalUrl: 'https://www.instagram.com/reel/ABC123/',
})
expect(
parseContentPieceLink('https://m.instagram.com/depjorgesolla/p/ABC123/?igsh=xyz'),
).toEqual({
origin: 'instagram',
shortcode: 'ABC123',
canonicalUrl: 'https://www.instagram.com/p/ABC123/',
})
})
it('refuses stories and malformed prefixed paths', () => {
expect(parseContentPieceLink('https://www.instagram.com/stories/depjorgesolla/123/')).toBeNull()
expect(parseContentPieceLink('https://www.instagram.com/stories/reel/ABC123/')).toBeNull()
expect(parseContentPieceLink('https://www.instagram.com/depjorgesolla/ABC123/')).toBeNull()
expect(
parseContentPieceLink('https://www.instagram.com/depjorgesolla/reel/ABC123/extra/'),
).toBeNull()
expect(parseContentPieceLink('https://instagr.am/reel/ABC123/')).toBeNull()
})
it('canonicalizes YouTube forms to the watch URL', () => {
expect(parseContentPieceLink('https://youtu.be/VIDEO1?t=10')).toEqual({
origin: 'youtube',
@ -265,6 +304,24 @@ describe('toContentPieceViewModel', () => {
expect(viewModel.canRetry).toBe(true)
expect(viewModel.failureMessage).toBe('Não foi possível processar a peça.')
})
it('maps the peça-link reason and ignores an unknown one', () => {
const withReason = toContentPieceViewModel({
id: 9,
processingStatus: 'pronto',
origin: 'instagram',
linkFailureReason: 'carrossel',
media: null,
})
expect(withReason.linkFailureReason).toBe('carrossel')
expect(withReason.linkFailureReasonLabel).toBe('Carrossel: sem arquivo único para baixar')
expect(withReason.failureMessage).toBeNull()
const unknown = toContentPieceViewModel({ id: 10, linkFailureReason: 'explodiu' })
expect(unknown.linkFailureReason).toBeNull()
expect(unknown.linkFailureReasonLabel).toBeNull()
})
})
describe('content piece list URL', () => {

View file

@ -5,6 +5,7 @@ import {
INSTAGRAM_MAX_RESULTS_CAP,
eligibleInstagramPosts,
loadInstagramFeed,
parseInstagramMediaPage,
parseInstagramMediaResponse,
pickInstagramThumbnail,
} from '@/utilities/socialFeed/instagramFeed'
@ -156,6 +157,35 @@ describe('parseInstagramMediaResponse', () => {
})
})
describe('parseInstagramMediaPage', () => {
it('reads the after cursor and ignores paging.next (it carries the token)', () => {
const page = parseInstagramMediaPage({
data: [MEDIA_ITEM('post1')],
paging: {
cursors: { before: 'before-cursor', after: 'next-cursor' },
next: 'https://graph.instagram.com/me/media?access_token=secret&after=next-cursor',
},
})
expect(page.posts.map((post) => post.id)).toEqual(['post1'])
expect(page.nextCursor).toBe('next-cursor')
})
it('reads a missing or malformed cursor as undefined', () => {
expect(parseInstagramMediaPage({ data: [] }).nextCursor).toBeUndefined()
expect(parseInstagramMediaPage({ data: [], paging: {} }).nextCursor).toBeUndefined()
expect(
parseInstagramMediaPage({ data: [], paging: { cursors: {} } }).nextCursor,
).toBeUndefined()
expect(
parseInstagramMediaPage({ data: [], paging: { cursors: { after: '' } } }).nextCursor,
).toBeUndefined()
expect(
parseInstagramMediaPage({ data: [], paging: { cursors: { after: 42 } } }).nextCursor,
).toBeUndefined()
})
})
describe('eligibleInstagramPosts', () => {
const posts: InstagramPost[] = [
{
@ -259,6 +289,87 @@ describe('loadInstagramFeed', () => {
expect(calls[1]).toContain('limit=1')
})
it('makes exactly one media call when the window fits one page (board contract)', async () => {
calls.length = 0
const result = await loadInstagramFeed({
...args,
maxResults: INSTAGRAM_MAX_RESULTS_CAP,
fetchImpl: async (input) => {
calls.push(input)
if (input.includes('/media')) {
return fakeResponse({
data: Array.from({ length: INSTAGRAM_MAX_RESULTS_CAP }, (_, index) =>
MEDIA_ITEM(`post${index}`),
),
paging: { cursors: { after: 'cursor-1' } },
})
}
return fakeResponse({ username: 'depjorgesolla' })
},
})
expect(result.posts).toHaveLength(INSTAGRAM_MAX_RESULTS_CAP)
expect(calls.filter((call) => call.includes('/media'))).toHaveLength(1)
})
it('walks the cursor while the window is not filled', async () => {
calls.length = 0
const result = await loadInstagramFeed({
...args,
maxResults: 3,
fetchImpl: async (input) => {
calls.push(input)
if (input.includes('/media')) {
if (input.includes('after=cursor-1')) {
return fakeResponse({ data: [MEDIA_ITEM('post3')] })
}
return fakeResponse({
data: [MEDIA_ITEM('post1'), MEDIA_ITEM('post2')],
paging: { cursors: { after: 'cursor-1' } },
})
}
return fakeResponse({ username: 'depjorgesolla' })
},
})
expect(result.posts.map((post) => post.id)).toEqual(['post1', 'post2', 'post3'])
const mediaCalls = calls.filter((call) => call.includes('/media'))
expect(mediaCalls).toHaveLength(2)
expect(mediaCalls[1]).toContain('after=cursor-1')
expect(mediaCalls[1]).toContain('limit=3')
})
it('refreshes once and refetches the whole window when a later page fails', async () => {
calls.length = 0
let mediaCalls = 0
const result = await loadInstagramFeed({
...args,
maxResults: 3,
fetchImpl: async (input) => {
calls.push(input)
if (input.includes('/refresh_access_token')) {
return fakeResponse({ access_token: 'refreshed-token' })
}
if (input.includes('/media')) {
mediaCalls += 1
if (mediaCalls === 2) return fakeResponse({ error: { message: 'boom' } }, false)
if (input.includes('after=cursor-1')) {
return fakeResponse({ data: [MEDIA_ITEM('post2')] })
}
return fakeResponse({
data: [MEDIA_ITEM('post1')],
paging: { cursors: { after: 'cursor-1' } },
})
}
return fakeResponse({ username: 'depjorgesolla' })
},
})
expect(result.refreshedAccessToken).toBe('refreshed-token')
expect(result.posts.map((post) => post.id)).toEqual(['post1', 'post2'])
expect(calls.filter((call) => call.includes('/refresh_access_token'))).toHaveLength(1)
})
it('refreshes the token once and retries when the media call fails', async () => {
calls.length = 0
let mediaFailures = 1