mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
fix(C220): link do proprio Instagram baixa e cataloga a peca (Closes #1302)
This commit is contained in:
parent
76416c77b0
commit
a54e61960b
20 changed files with 19518 additions and 121 deletions
1
docs/changelog/2026-09-24-c220.md
Normal file
1
docs/changelog/2026-09-24-c220.md
Normal 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.
|
||||
183
docs/plans/central-conteudos-link-instagram-impl.md
Normal file
183
docs/plans/central-conteudos-link-instagram-impl.md
Normal 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).
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ export const runContentPieceJob = async (
|
|||
processingStatus: 'pronto',
|
||||
step: null,
|
||||
error: null,
|
||||
linkFailureReason: source.linkFailureReason,
|
||||
...(durationSeconds !== null ? { durationSeconds } : {}),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const contentPieceDetailSelect = {
|
|||
sourceUrl: true,
|
||||
municipality: true,
|
||||
curatedFields: true,
|
||||
linkFailureReason: true,
|
||||
} as const
|
||||
|
||||
export const ContentPieceNotFoundError = createEntityNotFoundError(
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ export const attachContentPieceMedia = async ({
|
|||
processingStatus: processing ? 'processando' : 'pronto',
|
||||
...(processing ? { step: 'extraindo' } : {}),
|
||||
error: null,
|
||||
linkFailureReason: null,
|
||||
},
|
||||
depth: 0,
|
||||
user: actor,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue