From 4a07bac5962d87d8be32bd9b5041ce52766fc102 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 26 Sep 2026 00:34:37 -0300 Subject: [PATCH] =?UTF-8?q?feat(C230):=20importar=20publica=C3=A7=C3=B5es?= =?UTF-8?q?=20do=20perfil=20oficial=20na=20Central?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lê a janela recente (12 mídias) de @depjorgesolla pela Graph API oficial e cria Rascunho só para as novidades pelo pipeline C211/C220 (ou peça-link com motivo honesto), com progresso real e recibo novas/já estavam/falharam. Dedupe pela identidade do post (shortcode em qualquer grafia p/reel/tv), sem migration, compartilhado com o Adicionar por link; fail-closed de credencial nas duas pontas; nada é publicado. Closes #1362 --- docs/changelog/2026-09-25-c230.md | 1 + .../central-conteudos-importar-perfil-impl.md | 152 ++++++ scripts/lib/e2e-affected-manifest.mjs | 3 + .../conteudos/importar/criar/route.ts | 33 ++ .../comunicacao/conteudos/importar/route.ts | 33 ++ .../(app)/comunicacao/conteudos/page.tsx | 76 ++- .../(app)/comunicacao/conteudos/types.ts | 17 +- .../campanha/actions/contentPieces.ts | 62 ++- .../ImportContentPieceProfileDialog.tsx | 448 ++++++++++++++++++ src/lib/campaignPaths.ts | 8 + src/lib/contentPiece.ts | 71 ++- src/lib/schemas/contentPiece.ts | 26 +- src/utilities/content/contentPieceLink.ts | 38 ++ .../content/contentPieceProfileImport.ts | 237 +++++++++ tests/int/contentPiece.int.spec.ts | 253 ++++++++++ tests/unit/contentPiece.unit.spec.ts | 66 +++ 16 files changed, 1486 insertions(+), 38 deletions(-) create mode 100644 docs/changelog/2026-09-25-c230.md create mode 100644 docs/plans/central-conteudos-importar-perfil-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/criar/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route.ts create mode 100644 src/components/campaign/content/ImportContentPieceProfileDialog.tsx create mode 100644 src/utilities/content/contentPieceProfileImport.ts diff --git a/docs/changelog/2026-09-25-c230.md b/docs/changelog/2026-09-25-c230.md new file mode 100644 index 00000000..f41c0d5b --- /dev/null +++ b/docs/changelog/2026-09-25-c230.md @@ -0,0 +1 @@ +**Importar publicações do perfil oficial na Central de Conteúdos (C230, Issue #1362):** a lista da Central ganha o gesto "Importar do perfil": ele lê a janela recente (12 mídias) de `@depjorgesolla` pela Graph API oficial — o mesmo cliente do board, sem scraping, terceiro ou stories — e cria **Rascunho** apenas para as novidades, cada uma pelo pipeline C211/C220 (mídia baixada do perfil próprio, transcrita e catalogada) ou como **peça-link com motivo honesto** quando a API não oferece arquivo único (carrossel, áudio protegido). O fluxo é em duas fases com progresso real ("Importando X de N…") e o recibo final separa **novas criadas / já estavam / falharam**, com o detalhe das peça-link e a orientação de retentar sem duplicar. O dedupe é pela **identidade do post** (shortcode, em qualquer grafia `p`/`reel`/`tv`), não pela URL: o predicado é um só e passou a valer também para o "Adicionar por link" (C220), então um post já catalogado não gera gêmea em nenhum caminho — sem migration (o índice unique de `sourceUrl` já existia). Sem credencial a ação fica indisponível com a linguagem do board ("Instagram ainda não configurado") e o servidor recusa a chamada direta (**fail-closed** nas duas pontas); o token nunca cruza para o cliente, log ou wire, nenhuma peça é publicada automaticamente, `curatedFields` nunca é sobrescrito e o board/feed da home permanece intocado. Cobertura: unit das regras puras (variantes de identidade e classificação do metadado) e int do importador com feed injetado (candidatos/dedupe por kind/falha de credencial/feed, shape do rascunho e reimportação sem duplicata). diff --git a/docs/plans/central-conteudos-importar-perfil-impl.md b/docs/plans/central-conteudos-importar-perfil-impl.md new file mode 100644 index 00000000..b64d3cd0 --- /dev/null +++ b/docs/plans/central-conteudos-importar-perfil-impl.md @@ -0,0 +1,152 @@ +# Impl: C230 — Central de Conteúdos — importar publicações do perfil oficial (@depjorgesolla) + +Status: aprovado +Atualizado em: 2026-09-25 +Issue: #1362 +Intenção: docs/plans/central-conteudos-importar-perfil.md +Appetite restante: herdado (~1–2 dias eng) — sem corte novo: o plano reusa o pipeline C211/C220, o cliente Graph API e a tela da Central; backfill, agendador/webhook e scraping seguem fora (intenção). + +> Modo `--auto` (work-issue): plano nasce aprovado pelo agente. Design UI do gate: `docs/plans/central-conteudos-importar-perfil-ui-design.html` (cenas 1–7). Trigger de designer em **UI: non-trigger com adaptações registradas** — 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:** com a credencial configurada, "Importar do perfil" na lista da Central traz as mídias recentes de `@depjorgesolla`: cada novidade vira **Rascunho** no pipeline existente (mídia baixada do perfil próprio, transcrita, catalogada) ou **peça-link com motivo honesto**; ao fim o operador lê o recibo **novas / já estavam / falharam**; nada duplica — inclusive contra peça que entrou por link manual em outra grafia — e nada é publicado. +- **O que NÃO negociar:** + - Só o perfil próprio pela API oficial (`loadInstagramFeed`, `src/utilities/socialFeed/instagramFeed.ts:253-322`); **nunca** scraping, terceiro ou stories; **mídia de terceiro nunca é baixada** (o match é dentro do feed da própria conta). + - Tudo entra como **Rascunho**; nenhuma publicação automática; `curatedFields` nunca sobrescrito. + - Dedupe pela **identidade do post**, não pela grafia da URL; reexecutar não duplica. + - Sem credencial: **fail-closed** com a linguagem do board ("Instagram ainda não configurado"); token **nunca** em log/URL/wire. + - Board/feed da home intocado: `instagramFeed.ts` e o pipeline C220 (resolver/job/agendador) não mudam de comportamento; sem segundo resolvedor, sem estado na `SocialFeedSettings`. + - LGPD: **sem Consent novo** (não há PII de eleitor; peça é material de campanha). +- **O que reavaliar (hipóteses da intenção, confirmadas ou resolvidas no código):** + - "identidade do post exige campo novo" — reavaliada e **resolvida sem migration**: o parse produz a URL canônica e o conjunto de variantes vivas é ≤3 (`p|reel|tv`; `reels` colapsa em `reel` em `src/lib/contentPiece.ts:408-461`), consultável por `sourceUrl in` no índice existente (`src/collections/ContentPiece.ts:421-431`) — D1. + - "o dedupe exato do `addContentPieceByLinkForActor` basta" — **confirmado insuficiente** para "qualquer grafia" (`contentPieces.ts:335-344` compara `canonicalUrl` exata; `/p/ABC/` ≠ `/reel/ABC/`): o predicado vira compartilhado — D1. + - "a janela de 500 do C220 serve ao importador" — não: o recorte do aceite é "mídias recentes"; a janela vira decisão de implementação (12) e o histórico continua pelo link — D4. + - "o pipeline C220 re-resolve pelo feed por peça (N+1)" — confirmado e aceito: ~1 chamada de feed por post da janela, com early-stop (D3); o rate limit da edge (C212 §Q1) comporta 12. + - "falta credencial → só UI ou só server" — o fail-closed precisa das duas pontas: página (botão/banner) e action/rota (mensagem segura) — D5. + +## Abordagem recomendada + +```mermaid +flowchart LR + P["Página Conteúdos
lê o global → instagramConfigured (boolean)"] --> D["Dialog 'Importar do perfil'
intro · running · recibo"] + D -->|"Fase A · POST .../conteudos/importar"| A["lista candidatos
global + isInstagramFeedConfigured
loadInstagramFeed maxResults 12"] + A -->|"sem credencial"| E1["erro seguro
Instagram ainda não configurado"] + A -->|"feed lido"| DED["dedupe por identidade do post
sourceUrl in variantes p · reel · tv"] + DED --> R["found · existingCount · candidates[]
linkOnlyReason: carrossel · indisponivel · null"] + R -->|"loop no cliente · 1 POST por candidato"| B["Fase B · POST .../importar/criar"] + B --> C{"identidade já existe?"} + C -- "sim" --> EX["outcome existing → já estavam"] + C -- "não" --> CR["create no shape do link C220
rascunho · processando/extraindo"] + CR --> J["after() → runContentPieceJob
re-resolve por shortcode · baixa mídia · cataloga"] + J --> OK["pronto com arquivo
ou peça-link com linkFailureReason honesto"] + CR --> REC["recibo: novas / já estavam / falharam
+ peça-link por motivo"] + EX --> REC + REC --> L["Ver os rascunhos
lista ?status=rascunho"] +``` + +**Opções consideradas (geral):** A) importador como orquestração cliente-servidor em 2 fases, reusando o pipeline C220, com dedupe por identidade do post via variantes de `sourceUrl` (sem migration) e fail-closed de credencial nas duas pontas; B) campo indexado novo (`instagramShortcode`/`mediaId`) com migration + action única server-side; C) fila/cron/webhook com estado próprio de varredura. +**Recomendação:** **A** — um dono por mecanismo (feed Graph API, pipeline da peça, lista), zero schema novo, progresso real do design (cena 3) e cabe no appetite. +**Rejeitadas:** **B** — migration é stop do modo `--auto` e cara de reverter, sem ganho sobre a query `in` num índice existente; **C** — fora de escopo (item irmão C212 §Q4) e contradiz a cadência sob demanda desta fatia. + +### D1 — Dedupe por identidade do post, sem migration + +**Opções:** A) identidade = **shortcode do Instagram** → variantes canônicas de `sourceUrl` (`https://www.instagram.com/{p|reel|tv}/{shortcode}/`), consultadas com `where: { sourceUrl: { in: variantes } }`; para YouTube a identidade já é 1 URL canônica; predicado compartilhado com o owner C220; B) campo indexado novo `instagramShortcode`/`mediaId` (migration de schema) no dedupe; C) confiar só no unique exato de `sourceUrl` como hoje (`contentPieces.ts:335-344`). +**Recomendação:** **A** — o kind (`p`×`reel`×`tv`) é apresentação e o shortcode é a identidade (`src/lib/contentPiece.ts:384-396,408-461`); a query `in` usa o índice que `sourceUrl` já tem e o conjunto é ≤3 URLs por candidato numa janela de 12 — sem schema, migration ou backfill. O helper puro `contentPiecePostIdentityUrls(link)` é o único ponto com as variantes e `findExistingContentPieceByPostIdentity` o único predicado de dedupe; o `addContentPieceByLinkForActor` (owner C220) passa a usá-lo, então colar o link em outra grafia também responde `CONTENT_PIECE_LINK_DUPLICATE_MESSAGE` ("Esta peça já está na Central"). +**Rejeitadas:** **B** — migration sem ganho real (teto de 3 variantes, `reels` normaliza no parse) e cara de reverter; **C** — deixa passar a gêmea `/p/ABC/`×`/reel/ABC/`, violando o aceite ("inclusive o que entrou por link manual, em qualquer grafia"). + +### D2 — Fluxo em 2 fases orquestrado pelo cliente, com progresso real + +**Opções:** A) **Fase A** (rota `.../conteudos/importar`) lê o feed e devolve `{ found, existingCount, candidates:[{ url, shortcode, linkOnlyReason }] }` já deduplicado; **Fase B** (`.../importar/criar`) cria UMA peça por candidato, com loop sequencial no cliente ⇒ "Importando X de N…" real, falha de um não interrompe os outros e o recibo separa novas/já estavam/falhou; B) 1 action faz tudo server-side e devolve só o recibo final; C) fila/cron/webhook com estado próprio. +**Recomendação:** **A** — cumpre a cena 3 ("Importando 7 de 12…") sem progresso inventado, não trava uma request atrás de N jobs e isola cada falha (cena 4). Contrato: `found = existingCount + candidates.length` (só mídias com identidade reconhecível); o recibo fecha `created + existingFinal + failed = found`; corrida entre A e B (outro ator criou a peça) cai em `existing` no probe da Fase B. Fase B responde `{ status:'success', outcome:'created'|'existing' }` — o loop só precisa do desfecho e a lista é refrescada no fim (sem devolver `ContentPieceViewModel` nem leitura extra do doc existente). Loop em série (1 request por vez) — progresso honesto e carga previsível; o upload usa 2 em voo porque o gargalo é o navegador, aqui é a API/DB. +**Rejeitadas:** **B** — contradiz a cena 3 (sem progresso real) e segura a request com N jobs; **C** — fora de escopo (C212 §Q4). + +### D3 — Reuso integral do pipeline C220 (nada de segundo resolvedor) + +**Opções:** A) a Fase B cria a peça exatamente no shape do link — título `contentPieceLinkTitle(link)` (`Instagram · SHORTCODE`), `type:'video'`, `origin:'instagram'`, `sourceUrl` canônica, `status:'rascunho'`, `processingStatus:'processando'`, `step:'extraindo'` — e chama `startContentPieceJobInBackground`; o job C220 re-resolve pelo feed com early-stop, baixa só `mediaUrl` do perfil próprio, transcreve/cataloga e persiste `linkFailureReason` honesto; B) segundo resolvedor no importador (baixar `mediaUrl` na Fase A e passar adiante); C) persistir a classificação da Fase A como `linkFailureReason` sem o job re-resolver. +**Recomendação:** **A** — um dono por mecanismo (o job é dono do estado da peça; `contentPieceLink.ts` da extração), nenhuma semântica nova; o N+1 de ~1 chamada de feed por post da janela é aceitável no rate limit da edge e a reexecução segue idempotente. A classificação exibida no recibo vem só do metadado do feed (`CAROUSEL_ALBUM` → `carrossel`; `mediaUrl == null` → `indisponivel`; senão `null`) — sem valor novo no vocabulário fechado (`src/lib/contentPiece.ts:360-379`; adicionar valor seria migration). +**Rejeitadas:** **B** — duplicaria download/limite/erro tipado e criaria segunda semântica de peça-link; **C** — motivo persistido viraria palpite da Fase A, não o resultado do caminho oficial (que é quem tenta o download). + +### D4 — Janela = 12 mídias recentes + +**Opções:** A) `maxResults: 12` numa constante nomeada no módulo do importador (`CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW`), documentada como decisão revisável; B) 50/uma página cheia ou backfill do histórico; C) 3 (mesmo recorte do board). +**Recomendação:** **A** — o mock do design é "7 de 12" e o aceite fala "mídias recentes"; uma única página de feed (≤50) cobre a janela e o post antigo específico continua entrando pelo link C220 (janela 500 com early-stop). A constante fica ao lado do `maxResults` para revisão explícita. +**Rejeitadas:** **B** — vira backfill/rabbit hole do plano de intenção; **C** — pequeno demais para o uso (perde posts do fim de semana). + +### D5 — Fail-closed de credencial nas duas pontas + +**Opções:** A) a página lê o global (`payload.findGlobal({ slug: 'social-feed-settings', depth: 0 })`, mesmo precedente de `contentPieceLink.ts:228`) e passa `instagramConfigured` — boolean derivado de `isInstagramFeedConfigured`, **nunca o token** ⇒ botão desabilitado + legenda "Instagram ainda não configurado" + banner âmbar da cena 5; actions/rotas recusam sem credencial com mensagem segura; B) só UI; C) só server. +**Recomendação:** **A** — fail-closed nas duas pontas: a UI não oferece e o servidor recusa mesmo chamado direto (rota HTTP). O global continua dono da credencial (`src/globals/SocialFeedSettings.ts:63-66`); se o feed estiver desligado (`enabled:false`/`instagramEnabled:false`) o mesmo caminho vale — a via oficial está indisponível. O token nunca cruza para o cliente, log ou wire. +**Rejeitadas:** **B** — servidor confiaria no cliente (falha aberta); **C** — erro só no meio da ação (contradiz cena 5). + +### Componentes / mudanças + +- **`src/lib/contentPiece.ts`** (puro; dono do vocabulário): `contentPiecePostIdentityUrls(link)` (3 variantes IG `p|reel|tv`; 1 canônica para YouTube) e `contentPieceProfileCandidateFromPost({ permalink, mediaType, mediaUrl })` → `ContentPieceProfileCandidate | null` (`{ url: canonicalUrl, shortcode, linkOnlyReason }`; null defensivo quando o parse falha); o tipo nasce aqui (client-safe). Sem importar `utilities/`. +- **`src/utilities/content/contentPieceLink.ts`** (dono do link C220; **só ganha exports**, resolução intocada): `contentPieceExistsForPostIdentity({ payload, actor, link })` (o predicado único de dedupe, `user` + `overrideAccess:false`, compartilhado com a colagem C220) e `isContentPieceSourceUrlDuplicateError(error)` (a corrida contra o índice unique vira `existing`/duplicata nos dois paths). +- **`src/utilities/content/contentPieceProfileImport.ts`** (novo, `server-only`): `CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW = 12` + timeout do feed (30 s, espelho do C220); `readContentPieceProfileImportAvailability(payload)` (boolean); `listContentPieceProfileImportCandidates({ payload, actor, loadFeed?, fetchImpl? })` (global + fail-closed + `loadInstagramFeed(maxResults:12)` + persist best-effort do token refrescado + classificação + dedupe em UMA query `sourceUrl in` variantes); `createContentPieceFromProfilePost({ payload, actor, url, startJob? })` (parse IG, probe → `existing`, create no shape do link, unique violation → `existing`, job em background). +- **`src/lib/schemas/contentPiece.ts`**: `CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE = 'Instagram ainda não configurado.'`; `CONTENT_PIECE_PROFILE_IMPORT_FEED_ERROR_MESSAGE = 'Não foi possível falar com o Instagram agora. Tente novamente.'`; `contentPieceProfileImportRequestSchema = z.object({})` (Fase A; a Fase B reusa `contentPieceLinkRequestSchema`); `CONTENT_PIECE_PROFILE_IMPORT_SAFE_MESSAGES = [...CONTENT_PIECE_SAFE_MESSAGES, UNAVAILABLE, FEED_ERROR]`. +- **`src/lib/campaignPaths.ts`**: `CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_HREF` e `..._CREATE_HREF` (pt-BR na URL, como `link`/`status`/`enviar` — `:77-83`). +- **`src/app/(campaign)/campanha/actions/contentPieces.ts`**: `listContentPieceProfileImportCandidatesForActor()` e `createContentPieceFromProfilePostForActor({ url })` — finas, gate fresco `canReadCommunicationCatalog`; `addContentPieceByLinkForActor` troca o probe exato (`:335-344`) pelo predicado compartilhado (o catch de unique segue `:367-377`). +- **Rotas** `.../conteudos/importar/route.ts` e `.../conteudos/importar/criar/route.ts`: `campaignJsonMutationRoute` (`campaignJsonMutationRoute.ts:98-119`) + `dynamic='force-dynamic'`, mesmo envelope da `link/route.ts`. +- **`.../conteudos/types.ts`**: `ContentPieceProfileImportCandidatesResponse` e `ContentPieceProfileImportCreateResponse` (importam o tipo do candidato de `@/lib/contentPiece`). +- **`src/components/campaign/content/ImportContentPieceProfileDialog.tsx`** (novo, client): fases `intro | running | done` (+ `error`), port classe-a-classe das cenas 2/3/4 (bullets e caixa "Sem scraping" da cena 2; spinner + passo concluído + "Importando X de N…" da cena 3; contadores + linhas de peça-link/falha + "Ver os rascunhos" da cena 4); loop sequencial; `router.refresh()` ao concluir/fechar; bloqueia fechar enquanto roda (`onInteractOutside`/`onEscapeKeyDown`, precedente `ContentPieceUploadDialog.tsx:217-225`). +- **`.../comunicacao/conteudos/page.tsx`**: lê `readContentPieceProfileImportAvailability(payload)` e passa `instagramConfigured`; toolbar da cena 1 e empty state da cena 7 com a 3ª saída; mobile em grid da cena 6 ("Enviar peças" full width + par link/importar); banner âmbar da cena 5 quando não configurado. +- **`scripts/lib/e2e-affected-manifest.mjs`**: prefixo `src/lib/schemas/contentPiece.ts` na entry de campanha (`:460-515`) — mapeamento honesto para `campaignSpeechAcervo` em diffs futuros do schema (o prefixo genérico `src/lib/schemas` já mapeia o arquivo, então não há fail-closed). Como o próprio manifesto está em `HIGH_RISK_EXACT` (`scripts/lib/test-affected-core.mjs:115-157`), este PR roda o conjunto **curado** (que inclui `campaignSpeechAcervo`) — custo aceito. +- **Changelog:** `docs/changelog/2026-09-25-c230.md`. + +**Migration: sem migration** — nenhuma collection/global/field muda; `push:false` intacto; `contentPiece.sourceUrl` já é unique+index (`src/collections/ContentPiece.ts:421-431`); `pnpm generate:types` desnecessário (os tipos novos são de aplicação). + +**Access / Consent:** gate fresco `canReadCommunicationCatalog(actor.role)` (`src/lib/campaignRoles.ts:27-28`) nas duas actions, como todas as irmãs da vertical; access da collection é a barreira final; probe/create com `user: actor` + `overrideAccess:false`. Leitura do global via Local API sem `user` (bypass admin documentado, precedente `contentPieceLink.ts:228`) só para derivar o boolean; token nunca cruza. Rotas sob `campaignJsonMutationRoute` (same-origin estrutural). **Sem Consent novo** (sem PII de eleitor) e nenhuma chave hardcoded. + +**UI:** Impeccable **C** (encaixe na tela existente). Reusa `Dialog/Button/Alert/Spinner`, `postCampaignJson`, `buildContentPieceListHref` (client-safe, `contentPieceListUrl.ts:89-90`) e os shells de lista sem mudança. Trigger de designer **non-trigger** com adaptações registradas (mesma estrutura, só indicador/copy): (i) passo 1 da cena 3 começa com spinner + "Buscando as publicações recentes de @depjorgesolla…" e vira o check desenhado quando a Fase A responde; (ii) recibo com zero candidatos usa a cena 4 com subtítulo honesto ("Nenhuma novidade na janela recente — a Central já estava em dia."); (iii) falha da Fase A ganha `Alert` destructive no lugar da lista + "Tentar de novo"; (iv) a legenda do botão fica sob o trigger no desktop e some no grid mobile (o banner cobre o motivo). Qualquer outro estado não desenhado → o designer estende o artefato antes do markup. + +### Dados → forma (se aplicável) + +N/A — operação/curadoria, não analytics: o recibo é contagem operacional (novas/já estavam/falhas) e o detalhe é textual (motivo da peça-link), sem agregado, taxa, série, engajamento ou alcance. A intenção fixou "sem números do Instagram nesta tela"; métricas de post são C213 (fora). + +## Fases verificáveis + +1. **Tracer / server — quota ~50%:** lib pura (`contentPiecePostIdentityUrls` + classificação) com unit; `contentPieceProfileImport.ts` (Fase A e Fase B) com deps injetáveis; actions/rotas/schemas/paths; predicado compartilhado aplicado ao `addContentPieceByLinkForActor`; entry do manifesto. **Tracer bullet cedo:** int com `loadFeed` fake devolve candidatos já deduplicados (uma peça existente por variante de kind) e a Fase B cria rascunho `processando/extraindo` com `sourceUrl` canônica; reexecutar → `existing`. Só então a UI. +2. **UI — quota ~30%:** `ImportContentPieceProfileDialog` (intro/running/done + adaptações), toolbar/empty/banner e mobile da `page.tsx`; refresh da lista ao concluir. +3. **Gates — quota ~20%:** `pnpm gate:fast` (lint/format/typecheck/knip/cycles/unit); `pnpm test:int`; e2e curado (o PR é high-risk por editar o manifesto — rodar o conjunto curado não-zero); `pnpm push` via GitHub. + +### Testes previstos + +- **`tests/unit/contentPiece.unit.spec.ts`:** `contentPiecePostIdentityUrls` — `/p/ABC/` e `/reel/ABC/` produzem as mesmas 3 variantes (`p`/`reel`/`tv`), `reels` colapsado, YouTube 1 canônica; `contentPieceProfileCandidateFromPost` — `REEL`/`VIDEO`/`IMAGE` com `mediaUrl` → `linkOnlyReason: null`; `CAROUSEL_ALBUM` → `'carrossel'` mesmo com `mediaUrl`; `IMAGE` com `mediaUrl: null` → `'indisponivel'`; permalink fora do parse → `null`. +- **`tests/int/contentPiece.int.spec.ts` — novo describe C230 (deps injetadas, sem rede):** Fase A com `loadFeed` fake (reel extraível + carrossel + sem `mediaUrl` + um já existente no DB por variante `/p/` enquanto o feed traz `/reel/`) → `found`, `existingCount` e candidatos com `linkOnlyReason`; `receivedMaxResults === 12`; sem credencial (`setInstagramSettings(false)`) → erro seguro e `loadFeed` nunca chamado; feed lançando → `CONTENT_PIECE_PROFILE_IMPORT_FEED_ERROR_MESSAGE`. Fase B: shape exato do link, `startJob` injetado recebe o id; repetir URL → `'existing'` sem segunda linha; `/reel/` depois de `/p/` → `'existing'` (sem gêmea); advisor/leader → `CONTENT_PIECE_FORBIDDEN_MESSAGE`. Owner C220: peça existente em `/p/A/`, colar `/reel/A/` → `CONTENT_PIECE_LINK_DUPLICATE_MESSAGE` (pina o predicado único). +- **e2e — não entra.** O int cobre a fronteira (candidatos/existing/link-only, fail-closed de credencial e create sem duplicar) com `loadFeed` injetado; a suíte não tem credencial real e o `social-feed-settings` é estado compartilhado entre projetos; um novo consumidor seria vetor de flake sem cobrir o que o int cobre. A página continua exercitada por `campaignSpeechAcervo` (HTTP), que o diff acorda. **Gatilho:** se a fronteira HTTP das rotas regredir sem o int pegar (envelope/status), um e2e HTTP curto com o stub (`tests/e2e/instagram-stub.mjs`) semeia o global e restaura em `finally`. +- **Pins:** invariantes do `e2eAffectedManifest.unit.spec.ts` seguem verdes (prefixo coberto, nenhuma spec nova); `pnpm gate:fast`/int. + +## Rabbit holes / Não escopo (engenharia) + +- **Backfill/"já que varre, importa as 10K"** — janela 12; post antigo pelo link C220 (com early-stop na janela 500); painel de cursor é o item irmão C212 §Q4. +- **Fila/cron/webhook/estado de varredura** — nada é agendado; a fatia nasce sob demanda e preparada para cadência futura (`after()`, precedente C199). +- **Scraping/oEmbed/terceiro/yt-dlp/sessão** — permanentes; o resultado é peça-link com motivo + "Anexar arquivo original". +- **Álbum de carrossel** (baixar filhos, montar capa) — não; `carrossel` como peça-link. +- **Segundo resolvedor/segunda fila/segunda identidade** — proibido; uma peça por post, pipeline C220. +- **Respeitar `excludedItems` do board** — as exclusões são do board ("NÃO aparecer no board", `SocialFeedSettings.ts:175-186`); o importador traz a janela recente e a assessoria apaga o que não quer na Central. Gatilho: se o produto pedir o mesmo filtro aqui, decisão curta + design. +- **Auto-publicar recentes / analytics/engajamento / contador na home** — fora (C213, C211). +- **Nova tela de credencial / tocar snapshot-cache-kill switch-lock do board / alterar `instagramFeed.ts`** — o global é o dono; o diff do board é zero. + +## Riscos e mitigação + +- **N+1 de ~1 chamada de feed por post (job por peça):** janela 12 e early-stop mantêm o custo ≤12 chamadas de página por execução, bem dentro do teto da edge; reexecução idempotente. Se o rate limit reclamar, o caminho é o item de varredura com cursor próprio (fora). +- **Dedupe por variantes sem campo novo:** as 3 grafias canônicas cobrem tudo que o parse gera (query/hash são descartados; `reels` normaliza); o unique continua a última barreira e a corrida vira `existing`. +- **Permalink irreconhecível (kind novo na API):** defensivo e impossível nos kinds vivos — o post não vira candidato e não entra em `found`; **gatilho:** kind novo → atualizar `INSTAGRAM_KINDS` + variantes (item curto) e, se acontecer, o post não aparece na janela (não há perda silenciosa de algo já ingerido). +- **Jobs ainda `processando` no fim do recibo:** o recibo conta criações, não conclusão; a lista mostra o processamento e o `ContentPieceStatusRefresher` (`:16-62`) acompanha; falha do job aparece na peça (`falhou`) e o motivo na ficha — nunca como silêncio. +- **Credencial cai entre as fases:** Fase A recusa; o job grava `sem-credencial`/`indisponivel` na peça (C220); nenhuma peça some e o retry/anexo seguem disponíveis. +- **Estado compartilhado do global na página:** leitura read-only de um boolean; sem escrita, sem cache novo, sem tocar hooks/snapshot. +- **Manifesto → curated:** editar `e2e-affected-manifest.mjs` classifica o PR como high-risk e roda o conjunto curado (mais cobertura, custo previsível); a entry é o mapeamento durável para diffs futuros do schema. +- **Token:** `loadInstagramFeed` já ignora `paging.next` (token na URL); o módulo novo não loga settings nem URLs com `access_token`; o wire carrega só `canonicalUrl`/`shortcode`; a página só recebe o boolean. +- **Appetite:** o maior custo é o dialog; a ordem server→UI deixa o aceite coberto mesmo se o polish atrasar. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: com credencial, importar traz as recentes; cada novidade é Rascunho no pipeline C220 ou peça-link com motivo; recibo honesto (novas/já estavam/falharam + detalhe de peça-link); nada duplica inclusive link manual em outra grafia; nada é publicado; `curatedFields` preservado; só perfil próprio/caminho oficial; sem scraping/terceiro/stories; sem credencial → "Instagram ainda não configurado" nas duas pontas; token nunca em log; board/feed intocado. +- [ ] Invariantes AGENTS/engineering-standards: gate fresco `canReadCommunicationCatalog`; `user` + `overrideAccess:false` (bypass só com comentário já justificado no pipeline/leitura derivada do global); **sem migration/collection/Consent novo**; `lib/` não importa `utilities/`; copy pt-BR e identificadores em inglês; um dono por mecanismo (sem segundo resolvedor/fila/identidade); `instagramFeed.ts` e job/scheduler intocados (o `contentPieceLink.ts` só ganha o probe de identidade e o predicado de corrida — nenhuma mudança na resolução). +- [ ] Testes de domínio previstos (unit/int) verdes e pins atualizados; entry do manifesto adicionada com os invariantes do manifest verdes. +- [ ] `pnpm gate:fast` verde; `pnpm test:int` verde; e2e curado verde (manifesto high-risk); `pnpm push` via GitHub. + +## Self-score + +**Self-score decision-quality: 4/5.** (1) Decisões caras com `Opções/Recomendação/Rejeitadas` explícitas (dedupe/identidade sem migration, fluxo em 2 fases, reuso do pipeline, janela, fail-closed); (2) cabe no appetite herdado — reusa pipeline C211/C220, cliente do feed, rotas/actions/envelope, dialog precedente e shells da lista; (3) rabbit holes nomeados (backfill, scraping, álbum, segunda fila, auto-publicar, exclusões do board, painel de varredura); (4) depth check: variantes e classificação no dono puro, dedupe num predicado compartilhado (owner C220 incluso), orquestração nova só onde há volatilidade real, sem pass-through; (5) aceite de produto intacto. Não 5/5 por duas incertezas assumidas honestamente: os estados não desenhados do dialog (buscando/zero/erro) resolvidos por adaptações registradas sob `non-trigger`, e o contrato de progresso N (`found = existing + candidatos`) inferido do mock, que pode ser revisado na execução se o design pedir outra leitura. diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 454995c5..e1c73351 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -505,6 +505,9 @@ export const E2E_AFFECTED_MANIFEST = [ 'src/lib/reel', 'src/lib/recording', 'src/lib/contentPiece', + // C230 — the piece request schemas and their safe messages (the risk + // area maps to the vertical it belongs to). + 'src/lib/schemas/contentPiece.ts', // S37 — the public-figure catalog of the ficha picker. 'src/lib/publicFigureCatalog.ts', 'src/lib/privateMedia', diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/criar/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/criar/route.ts new file mode 100644 index 00000000..da54359a --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/criar/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server' + +import { createContentPieceFromProfilePostForActor } from '@/app/(campaign)/campanha/actions/contentPieces' +import { + CONTENT_PIECE_GENERIC_ERROR_MESSAGE, + CONTENT_PIECE_PROFILE_IMPORT_SAFE_MESSAGES, + contentPieceLinkRequestSchema, +} from '@/lib/schemas/contentPiece' +import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' + +import type { ContentPieceProfileImportCreateResponse } from '../../types' + +export const dynamic = 'force-dynamic' + +/** + * C230 — creates one draft from one listed profile media through the C220 + * pipeline. The dialog calls it once per candidate, so a failure is isolated + * to that publication and the others still import. + */ +export const POST = campaignJsonMutationRoute( + { + bodySchema: contentPieceLinkRequestSchema, + safeMessages: CONTENT_PIECE_PROFILE_IMPORT_SAFE_MESSAGES, + genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE, + }, + async (body) => { + const result = await createContentPieceFromProfilePostForActor(body) + return NextResponse.json({ + status: 'success', + outcome: result.outcome, + }) + }, +) diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route.ts new file mode 100644 index 00000000..de05a10d --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server' + +import { listContentPieceProfileImportCandidatesForActor } from '@/app/(campaign)/campanha/actions/contentPieces' +import { + CONTENT_PIECE_GENERIC_ERROR_MESSAGE, + CONTENT_PIECE_PROFILE_IMPORT_SAFE_MESSAGES, + contentPieceProfileImportRequestSchema, +} from '@/lib/schemas/contentPiece' +import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' + +import type { ContentPieceProfileImportCandidatesResponse } from '../types' + +export const dynamic = 'force-dynamic' + +/** + * C230 — lists the novelties of the official Instagram profile. The action + * owns the fail-closed credential check, the feed window and the dedupe by + * post identity; this route is the JSON door. + */ +export const POST = campaignJsonMutationRoute( + { + bodySchema: contentPieceProfileImportRequestSchema, + safeMessages: CONTENT_PIECE_PROFILE_IMPORT_SAFE_MESSAGES, + genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE, + }, + async () => { + const listing = await listContentPieceProfileImportCandidatesForActor() + return NextResponse.json({ + status: 'success', + ...listing, + }) + }, +) diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx index 02c7405c..fcb44b7f 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx @@ -1,5 +1,5 @@ import config from '@payload-config' -import { PackageOpenIcon } from 'lucide-react' +import { CircleAlertIcon, PackageOpenIcon } from 'lucide-react' import { redirect } from 'next/navigation' import { getPayload } from 'payload' @@ -9,6 +9,7 @@ import { ContentPieceFilters } from '@/components/campaign/content/ContentPieceF import { ContentPieceStatusRefresher } from '@/components/campaign/content/ContentPieceStatusRefresher' import { ContentPieceTable } from '@/components/campaign/content/ContentPieceTable' import { ContentPieceUploadDialog } from '@/components/campaign/content/ContentPieceUploadDialog' +import { ImportContentPieceProfileDialog } from '@/components/campaign/content/ImportContentPieceProfileDialog' import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing' import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' @@ -19,10 +20,12 @@ import { import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE } from '@/lib/schemas/contentPiece' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { buildContentPieceListHref } from '@/utilities/content/contentPieceListUrl' import { loadContentPieceListPageData } from '@/utilities/content/contentPiecePageData' +import { readContentPieceProfileImportAvailability } from '@/utilities/content/contentPieceProfileImport' export const metadata = campaignPageMetadataFromCatalog('conteudos') @@ -32,8 +35,9 @@ type ContentPiecesPageProps = { /** * C211 — the internal Central de Conteúdos: every piece the assessoria sent, - * with the honest processing state, the publication kill switch and the two - * ways in (batch upload, add by link). The public Central is S27. + * with the honest processing state, the publication kill switch and the three + * ways in (batch upload, add by link, import from the official profile — C230). + * The public Central is S27. */ export default async function ContentPiecesPage({ searchParams }: ContentPiecesPageProps) { const [user, payload] = await Promise.all([ @@ -41,9 +45,10 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP getPayload({ config }), ]) - const [columnVisibility, data] = await Promise.all([ + const [columnVisibility, data, instagramConfigured] = await Promise.all([ readCampaignColumnVisibility('conteudos'), loadContentPieceListPageData(payload, user, searchParams), + readContentPieceProfileImportAvailability(payload), ]) if (data.redirectHref) redirect(data.redirectHref) @@ -64,12 +69,17 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP
+
+

+ Importar usa somente o perfil oficial e nunca publica automaticamente. +

) @@ -77,27 +87,40 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP -
-
+
+

Conteúdos

Peças que alimentam a Central pública. Rascunhos não aparecem no site.

- {/* Mobile: the primary "Enviar peças" is full width and the link - action is a compact secondary (approved scene 5); desktop keeps - both side by side in the design's order. */} -
- - Adicionar por link - Link - - } - triggerClassName="max-sm:w-full" - /> - + {/* Mobile (scene 6): the primary "Enviar peças" is full width on top + and link/import sit in a two-column secondary row; desktop keeps + the design's order (import, link, send). */} +
+ +
+
+ + {!instagramConfigured ? ( +

+ {CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE} +

+ ) : null} +
+ + Adicionar por link + Adicionar link + + } + triggerClassName="max-sm:w-full" + /> +
@@ -108,6 +131,17 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP } /> + {!instagramConfigured ? ( +
+
+ ) : null} + => { + const { payload, actor } = await getCampaignActionContext() + + if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE) + + return listContentPieceProfileImportCandidates({ payload, actor }) + } + +/** + * C230 — creates ONE draft from ONE listed media through the C220 pipeline + * (`existing` covers a repeated/concurrent import). The dialog calls it per + * candidate so one failure never stops the others. + */ +export const createContentPieceFromProfilePostForActor = async (input: { + url: string +}): Promise<{ outcome: ContentPieceProfileImportOutcome }> => { + const parsed = contentPieceLinkRequestSchema.parse(input) + const { payload, actor } = await getCampaignActionContext() + + if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE) + + const outcome = await createContentPieceFromProfilePost({ payload, actor, url: parsed.url }) + return { outcome } +} + /** * S37 — the ficha's leader picker search. Same communication gate as every * action of the vertical; the returned options carry ONLY `{ id, label }` (the diff --git a/src/components/campaign/content/ImportContentPieceProfileDialog.tsx b/src/components/campaign/content/ImportContentPieceProfileDialog.tsx new file mode 100644 index 00000000..23250af2 --- /dev/null +++ b/src/components/campaign/content/ImportContentPieceProfileDialog.tsx @@ -0,0 +1,448 @@ +'use client' + +import { CheckIcon, CircleAlertIcon, InstagramIcon } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useState } from 'react' + +import type { + ContentPieceProfileImportCandidatesResponse, + ContentPieceProfileImportCreateResponse, +} from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/types' +import { Alert, AlertDescription } from '@/components/ui/Alert' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Spinner } from '@/components/ui/Spinner' +import { postCampaignJson } from '@/lib/campaignJsonRequest' +import { + CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_CREATE_HREF, + CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_HREF, +} from '@/lib/campaignPaths' +import type { ContentPieceProfileLinkOnlyReason } from '@/lib/contentPiece' +import { cn } from '@/lib/utils' +import { buildContentPieceListHref } from '@/utilities/content/contentPieceListUrl' + +const GENERIC_ERROR_MESSAGE = 'Não foi possível concluir a importação. Tente novamente.' + +const PROFILE_HANDLE = '@depjorgesolla' + +type ImportPhase = 'intro' | 'running' | 'done' + +type ImportResult = { + created: number + existing: number + failed: number + /** The created peças-link, one entry per publication, with the honest reason. */ + linkOnly: ContentPieceProfileLinkOnlyReason[] + /** First non-generic failure message of the loop, shown in the receipt. */ + failureMessage: string | null +} + +const IMPORT_STATUS: Record< + ContentPieceProfileLinkOnlyReason, + { title: string; description: string } +> = { + carrossel: { + title: 'carrossel', + description: + 'A API oficial não ofereceu um arquivo único. A publicação entrou pelo link, com este motivo na ficha.', + }, + indisponivel: { + title: 'sem arquivo extraível', + description: + 'O arquivo não pôde ser extraído pela via oficial. A publicação não foi descartada.', + }, +} + +/** Counts the created peças-link per reason, in first-seen order. */ +const countByReason = ( + reasons: readonly ContentPieceProfileLinkOnlyReason[], +): [ContentPieceProfileLinkOnlyReason, number][] => { + const counts = new Map() + for (const reason of reasons) counts.set(reason, (counts.get(reason) ?? 0) + 1) + return [...counts] +} + +/** + * C230 — "Importar do perfil" (approved design scenes 2–4): the assessoria + * brings the recent publications of the official profile into the Central. + * The dialog drives the two phases for real — the listing (Graph API, own + * profile only, dedupe by post identity) and one creation per novelty through + * the C220 pipeline — so "Importando X de N…" is progress, never decoration. + * Nothing is published: every novelty lands as Rascunho for curation. + */ +export const ImportContentPieceProfileDialog = ({ + configured, + triggerClassName, +}: { + /** False hides the action behind the design's fail-closed caption/disabled button. */ + configured: boolean + triggerClassName?: string +}) => { + const router = useRouter() + const [open, setOpen] = useState(false) + const [phase, setPhase] = useState('intro') + const [listing, setListing] = useState(false) + const [progress, setProgress] = useState({ done: 0, total: 0 }) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + + const reset = () => { + setPhase('intro') + setListing(false) + setProgress({ done: 0, total: 0 }) + setResult(null) + setError(null) + } + + const runImport = async () => { + setPhase('running') + setListing(true) + setProgress({ done: 0, total: 0 }) + setResult(null) + setError(null) + + let candidates: ContentPieceProfileImportCandidatesResponse & { status: 'success' } + try { + const { ok, payload } = await postCampaignJson( + CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_HREF, + {}, + ) + if (!ok || payload.status !== 'success') { + setError(payload.status === 'error' ? payload.message : GENERIC_ERROR_MESSAGE) + setPhase('intro') + return + } + candidates = payload + } catch { + setError(GENERIC_ERROR_MESSAGE) + setPhase('intro') + return + } + + setListing(false) + setProgress({ done: 0, total: candidates.candidates.length }) + + let created = 0 + let existing = candidates.existingCount + let failed = 0 + let failureMessage: string | null = null + const linkOnly: ContentPieceProfileLinkOnlyReason[] = [] + + for (const [index, candidate] of candidates.candidates.entries()) { + try { + const { ok, payload } = await postCampaignJson( + CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_CREATE_HREF, + { url: candidate.url }, + ) + if (ok && payload.status === 'success') { + if (payload.outcome === 'created') { + created += 1 + if (candidate.linkOnlyReason) linkOnly.push(candidate.linkOnlyReason) + } else { + existing += 1 + } + } else { + failed += 1 + // A safe domain message (e.g. expired session) is more honest than + // the generic network copy; the first one names the whole receipt. + if (payload.status === 'error') failureMessage ??= payload.message + } + } catch { + failed += 1 + } + setProgress({ done: index + 1, total: candidates.candidates.length }) + } + + setResult({ created, existing, failed, linkOnly, failureMessage }) + setPhase('done') + router.refresh() + } + + const linkOnlyRows = result ? countByReason(result.linkOnly) : [] + + return ( + { + if (phase === 'running') return + setOpen(next) + if (!next) reset() + }} + > + + + + { + if (phase === 'running') event.preventDefault() + }} + onEscapeKeyDown={(event) => { + if (phase === 'running') event.preventDefault() + }} + > + {phase === 'done' && result ? ( + <> + +
+
+ Importação concluída + + {result.created === 0 && result.failed === 0 + ? 'Nenhuma novidade na janela recente — a Central já estava em dia.' + : 'As novidades já estão na Central como rascunho. Nada foi publicado.'} + +
+ +
+
+ {result.created} + novas criadas +
+
+ {result.existing} + já estavam +
+
+ {result.failed} + + {result.failed === 1 ? 'falhou' : 'falharam'} + +
+
+ + {linkOnlyRows.length > 0 || result.failed > 0 ? ( +
+ {linkOnlyRows.map(([reason, count], index) => { + const status = IMPORT_STATUS[reason] + return ( +
0 ? 'border-b' : '', + )} + > + + Peça-link + +
+ + {count} {status.title} · criado como peça-link + +

{status.description}

+
+
+ ) + })} + {result.failed > 0 ? ( +
+
+ ) : null} +
+ ) : null} + + + + + + + ) : phase === 'running' ? ( + <> + +
+ +
+ Importando do perfil + Mantenha esta janela aberta até concluir. +
+
+
+ +
+
+
+ {listing ? ( + + ) : ( +
+
+ + {listing + ? 'Buscando as publicações recentes…' + : 'Publicações recentes encontradas'} + +

+ {listing + ? `Consultando ${PROFILE_HANDLE} pela API oficial.` + : `A busca foi feita em ${PROFILE_HANDLE} pela API oficial.`} +

+
+
+ + {!listing && progress.total > 0 ? ( + <> +
+
+ +
+ + Importando {Math.min(progress.done + 1, progress.total)} de {progress.total} + … + +

+ Criando apenas as novidades como rascunho e reconhecendo o que já estava na + Central. +

+
+
+ + ) : null} +
+ +

+ Algumas mídias podem levar mais tempo para serem preparadas. O resultado final separa + novas, já existentes e falhas — nenhuma falha fica escondida. +

+ + + + + + ) : ( + <> + +
+
+ Importar do perfil + + Vamos procurar as publicações recentes de{' '} + {PROFILE_HANDLE} que ainda não estão na Central. + +
+ +
+
+
+
+
+
+
+ +
+ Sem scraping. +
+ Só o perfil oficial {PROFILE_HANDLE}, pela API oficial. Carrossel ou mídia sem + arquivo extraível continua como peça-link, com o motivo visível. +
+ + {error ? ( + + + ) : null} +
+ + + + + + + )} + +
+ ) +} diff --git a/src/lib/campaignPaths.ts b/src/lib/campaignPaths.ts index cdd74aeb..21a27a3d 100644 --- a/src/lib/campaignPaths.ts +++ b/src/lib/campaignPaths.ts @@ -82,6 +82,14 @@ export const CAMPAIGN_CONTENT_PIECE_LINK_HREF = `${CAMPAIGN_COMMUNICATION_CONTEU export const CAMPAIGN_CONTENT_PIECE_STATUS_HREF = `${CAMPAIGN_COMMUNICATION_CONTEUDOS}/status` as const +/** C230 — list the novelties of the official Instagram profile. */ +export const CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_HREF = + `${CAMPAIGN_COMMUNICATION_CONTEUDOS}/importar` as const + +/** C230 — create one piece from one listed profile media. */ +export const CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_CREATE_HREF = + `${CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_HREF}/criar` as const + export const campaignContentPieceFileHref = (id: number, download = false): string => `${campaignContentPieceDetailHref(id)}/arquivo${download ? '?download=1' : ''}` diff --git a/src/lib/contentPiece.ts b/src/lib/contentPiece.ts index 4c0b1e48..73a9d265 100644 --- a/src/lib/contentPiece.ts +++ b/src/lib/contentPiece.ts @@ -395,7 +395,14 @@ type ContentPieceYoutubeLink = { export type ContentPieceLink = ContentPieceInstagramLink | ContentPieceYoutubeLink -const INSTAGRAM_KINDS = ['p', 'reel', 'reels', 'tv'] +/** + * The canonical path kinds of an Instagram post. The kind is presentation and + * the shortcode is the identity, so the identity probe spells all of them. + */ +const INSTAGRAM_CANONICAL_KINDS = ['p', 'reel', 'tv'] + +/** The kinds the paste accepts — `reels` is the same presentation as `reel`. */ +const INSTAGRAM_KINDS: readonly string[] = [...INSTAGRAM_CANONICAL_KINDS, 'reels'] /** * Normalizes an Instagram or YouTube link into its canonical form, dropping @@ -469,6 +476,68 @@ export const contentPieceOriginFromLink = ( export const contentPieceLinkTitle = (link: ContentPieceLink): string => link.origin === 'instagram' ? `Instagram · ${link.shortcode}` : `YouTube · ${link.videoId}` +/** + * C230 — every canonical spelling of one post's `sourceUrl`. The Instagram + * kind in the path (`p`, `reel`, `tv`) is presentation: the same post can be + * pasted or served under another kind, so the identity is the shortcode and a + * catalogue probe matches the piece through all its spellings in one `in` + * query over the unique `sourceUrl` index. YouTube has a single canonical URL + * — the video id is already the whole identity. + */ +export const contentPiecePostIdentityUrls = (link: ContentPieceLink): string[] => + link.origin === 'instagram' + ? INSTAGRAM_CANONICAL_KINDS.map( + (kind) => `https://www.instagram.com/${kind}/${link.shortcode}/`, + ) + : [link.canonicalUrl] + +/** One media item of the official profile feed, as the C230 import reads it. */ +export type ContentPieceProfilePost = { + permalink: string + mediaType: string + mediaUrl?: string | null +} + +/** + * The link-only reasons the profile metadata alone can tell: a carousel never + * offers a single file, and the API omits `media_url` on protected media. The + * other reasons only the extraction job can find, so the listing never claims + * them. + */ +export type ContentPieceProfileLinkOnlyReason = Extract< + ContentPieceLinkFailureReason, + 'carrossel' | 'indisponivel' +> + +export type ContentPieceProfileCandidate = { + /** The canonical link the piece will carry — what the import then creates. */ + url: string + /** + * Why the piece will circulate only by its link, told by the feed metadata + * before the job runs. Null means the job will try the official file. + */ + linkOnlyReason: ContentPieceProfileLinkOnlyReason | null +} + +/** + * C230 — classifies one feed media into an import candidate. The official + * metadata already answers whether a single file is offered (a carousel never + * has one; protected audio omits `media_url`), so the receipt can name the + * peça-link outcome honestly. A permalink the catalogue cannot parse is not a + * candidate at all — the importer never invents an identity. + */ +export const contentPieceProfileCandidateFromPost = ( + post: ContentPieceProfilePost, +): ContentPieceProfileCandidate | null => { + const link = parseContentPieceLink(post.permalink) + if (!link) return null + return { + url: link.canonicalUrl, + linkOnlyReason: + post.mediaType === 'CAROUSEL_ALBUM' ? 'carrossel' : post.mediaUrl ? null : 'indisponivel', + } +} + export type ContentPieceViewModel = { id: number title: string diff --git a/src/lib/schemas/contentPiece.ts b/src/lib/schemas/contentPiece.ts index b6355309..514eed0b 100644 --- a/src/lib/schemas/contentPiece.ts +++ b/src/lib/schemas/contentPiece.ts @@ -30,9 +30,16 @@ export const CONTENT_PIECE_GENERIC_ERROR_MESSAGE = /** The pasted link is not an Instagram/YouTube publication. */ export const CONTENT_PIECE_LINK_INVALID_MESSAGE = 'Cole um link do Instagram ou do YouTube.' -/** The same link is already catalogued (the `sourceUrl` unique constraint). */ +/** The same link is already catalogued (the `sourceUrl` identity probe). */ export const CONTENT_PIECE_LINK_DUPLICATE_MESSAGE = 'Esta peça já está na Central.' +/** C230 — the official Instagram credential is missing or the feed is off. */ +export const CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE = 'Instagram ainda não configurado.' + +/** C230 — the Graph API did not answer the profile listing. */ +export const CONTENT_PIECE_PROFILE_IMPORT_FEED_ERROR_MESSAGE = + 'Não foi possível falar com o Instagram agora. Tente novamente.' + /** The upload arrived without a usable file name (a field error). */ const CONTENT_PIECE_FILE_NAME_MESSAGE = 'Nome de arquivo inválido.' @@ -74,6 +81,13 @@ export const contentPieceLinkRequestSchema = z.object({ url: z.string().trim().min(1, CONTENT_PIECE_LINK_INVALID_MESSAGE), }) +/** + * C230 — list the novelties of the official profile: the request carries no + * input (the window is the server's decision), so the schema only keeps the + * envelope honest. + */ +export const contentPieceProfileImportRequestSchema = z.object({}) + /** The editable catalogue of one piece (the ficha form). */ export const contentPieceUpdateRequestSchema = z.object({ contentPieceId: z.number().int().positive(), @@ -148,6 +162,16 @@ export const CONTENT_PIECE_SAFE_MESSAGES = [ CONTENT_PIECE_TYPE_INVALID_MESSAGE, ] as const +/** + * The messages the C230 profile-import routes may return verbatim: the shared + * piece messages plus the feed failure and the fail-closed credential refusal. + */ +export const CONTENT_PIECE_PROFILE_IMPORT_SAFE_MESSAGES = [ + ...CONTENT_PIECE_SAFE_MESSAGES, + CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE, + CONTENT_PIECE_PROFILE_IMPORT_FEED_ERROR_MESSAGE, +] as const + /** * The messages the two raw-body piece routes (upload and attach) may return * verbatim; the JSON routes use `CONTENT_PIECE_SAFE_MESSAGES` alone. diff --git a/src/utilities/content/contentPieceLink.ts b/src/utilities/content/contentPieceLink.ts index 2d6093cd..c53ab31a 100644 --- a/src/utilities/content/contentPieceLink.ts +++ b/src/utilities/content/contentPieceLink.ts @@ -8,10 +8,13 @@ import type { Payload } from 'payload' import { CONTENT_MEDIA_SLUG, CONTENT_PIECE_MAX_BYTES, + contentPiecePostIdentityUrls, parseContentPieceLink, + type ContentPieceLink, type ContentPieceLinkFailureReason, type ContentPieceType, } from '@/lib/contentPiece' +import type { CampaignUser } from '@/payload-types' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { isInstagramFeedConfigured, @@ -78,6 +81,41 @@ const matchesInstagramShortcode = return parsed?.origin === 'instagram' && parsed.shortcode === shortcode } +/** + * C230 — the one identity probe of the vertical: a post is already catalogued + * when ANY canonical spelling of its shortcode is a `sourceUrl`. The paste + * path (C220) and the profile importer (C230) share it, so pasting `/p/ABC/` + * answers "já está na Central" for a piece that entered as `/reel/ABC/`. + */ +export const contentPieceExistsForPostIdentity = async ({ + payload, + actor, + link, +}: { + payload: Payload + actor: CampaignUser + link: ContentPieceLink +}): Promise => { + const found = await payload.find({ + collection: 'contentPiece', + where: { sourceUrl: { in: contentPiecePostIdentityUrls(link) } }, + depth: 0, + limit: 1, + pagination: false, + user: actor, + overrideAccess: false, + }) + return found.docs.length > 0 +} + +/** + * A concurrent paste/import can still hit the `sourceUrl` unique index; the + * caller maps it to the same "already catalogued" answer as the identity + * probe. One predicate, so both creation paths cannot drift apart. + */ +export const isContentPieceSourceUrlDuplicateError = (error: unknown): boolean => + error instanceof Error && /source_url|sourceUrl|duplicate key|unique/i.test(error.message) + /** A hung Graph API must not hold the piece `processando` until the 1 h reaper. */ const CONTENT_PIECE_LINK_FEED_TIMEOUT_MS = 30_000 diff --git a/src/utilities/content/contentPieceProfileImport.ts b/src/utilities/content/contentPieceProfileImport.ts new file mode 100644 index 00000000..cf67db9b --- /dev/null +++ b/src/utilities/content/contentPieceProfileImport.ts @@ -0,0 +1,237 @@ +import 'server-only' + +import type { Payload } from 'payload' + +import { + contentPieceLinkTitle, + contentPiecePostIdentityUrls, + contentPieceProfileCandidateFromPost, + parseContentPieceLink, + type ContentPieceLink, + type ContentPieceProfileCandidate, +} from '@/lib/contentPiece' +import { + CONTENT_PIECE_LINK_INVALID_MESSAGE, + CONTENT_PIECE_PROFILE_IMPORT_FEED_ERROR_MESSAGE, + CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE, +} from '@/lib/schemas/contentPiece' +import type { CampaignUser } from '@/payload-types' +import { + contentPieceExistsForPostIdentity, + isContentPieceSourceUrlDuplicateError, +} from '@/utilities/content/contentPieceLink' +import { startContentPieceJobInBackground } from '@/utilities/content/contentPieceScheduler' +import { + isInstagramFeedConfigured, + loadInstagramFeed, + persistInstagramAccessToken, +} from '@/utilities/socialFeed/instagramFeed' + +/** + * C230 — the profile importer: reads the recent media of the campaign's OWN + * Instagram profile through the official Graph API client (no scraping, no + * third-party media) and turns each novelty into a draft through the C211/C220 + * pipeline. The identity of a post is its shortcode, so the dedupe probe + * matches the piece in any URL spelling — and the paste path (C220) shares the + * same probe, so a post never gets a twin. + */ + +/** The recency window of one import (12 media ≈ the profile's last days). */ +export const CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW = 12 + +/** A hung Graph API must not hold the confirm dialog until the job reaper. */ +const CONTENT_PIECE_PROFILE_IMPORT_FEED_TIMEOUT_MS = 30_000 + +type FeedLoader = typeof loadInstagramFeed +type FetchLike = (input: string, requestInit?: RequestInit) => Promise + +export type ContentPieceProfileImportListing = { + /** Media of the window the API answered with (parseable as profile posts). */ + found: number + /** How many of them already have a piece in the Central, in ANY URL spelling. */ + existingCount: number + /** The novelties, in feed order (newest first). */ + candidates: ContentPieceProfileCandidate[] +} + +export type ContentPieceProfileImportOutcome = 'created' | 'existing' + +const cataloguedSourceUrls = async ({ + payload, + actor, + identityUrls, +}: { + payload: Payload + actor: CampaignUser + identityUrls: readonly string[] +}): Promise> => { + if (identityUrls.length === 0) return new Set() + + const found = await payload.find({ + collection: 'contentPiece', + where: { sourceUrl: { in: [...new Set(identityUrls)] } }, + depth: 0, + limit: 0, + pagination: false, + select: { sourceUrl: true }, + user: actor, + overrideAccess: false, + }) + + return new Set( + found.docs + .map((doc) => doc.sourceUrl) + .filter((sourceUrl): sourceUrl is string => Boolean(sourceUrl)), + ) +} + +/** True when the official credential is armed — what arms the import button. */ +export const readContentPieceProfileImportAvailability = async ( + payload: Payload, +): Promise => { + // Intentional admin bypass: only the derived boolean crosses to the page; + // the credential itself never leaves this module (same as the link path). + const settings = await payload.findGlobal({ slug: 'social-feed-settings', depth: 0 }) + return isInstagramFeedConfigured(settings) +} + +/** + * Reads the own-profile feed with the global's credential, fail-closed: an + * unconfigured credential never reaches the API, and a feed failure becomes + * the honest product message instead of a raw transport error. A refreshed + * token is persisted best-effort, exactly like the link path. + */ +const loadConfiguredInstagramFeed = async ({ + payload, + loadFeed, + fetchImpl, +}: { + payload: Payload + loadFeed: FeedLoader + fetchImpl: FetchLike +}): Promise>> => { + // Intentional admin bypass: the global's read access is admin-only, and the + // listing is already behind the communication gate; the token stays inside + // this call and never reaches the wire or a log. + const settings = await payload.findGlobal({ slug: 'social-feed-settings', depth: 0 }) + if (!isInstagramFeedConfigured(settings)) { + throw new Error(CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE) + } + + let feed: Awaited> + try { + feed = await loadFeed({ + accessToken: settings.instagramAccessToken as string, + userId: settings.instagramUserId as string, + maxResults: CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW, + fetchImpl, + signal: AbortSignal.timeout(CONTENT_PIECE_PROFILE_IMPORT_FEED_TIMEOUT_MS), + }) + } catch { + // Token expired, API down: the operator reads the retry copy, never a + // transport detail. The token itself is never part of the error. + throw new Error(CONTENT_PIECE_PROFILE_IMPORT_FEED_ERROR_MESSAGE) + } + + if (feed.refreshedAccessToken) { + await persistInstagramAccessToken(payload, feed.refreshedAccessToken).catch(() => undefined) + } + return feed +} + +/** + * Lists the window's novelties, already deduplicated by post identity: `found` + * is what the API answered with (parseable), `existingCount` what the Central + * already had and `candidates` what the confirmation will create, newest + * first. + */ +export const listContentPieceProfileImportCandidates = async ({ + payload, + actor, + loadFeed = loadInstagramFeed, + fetchImpl = fetch, +}: { + payload: Payload + actor: CampaignUser + loadFeed?: FeedLoader + fetchImpl?: FetchLike +}): Promise => { + const feed = await loadConfiguredInstagramFeed({ payload, loadFeed, fetchImpl }) + + const parsed: { link: ContentPieceLink; candidate: ContentPieceProfileCandidate }[] = [] + for (const post of feed.posts) { + const candidate = contentPieceProfileCandidateFromPost(post) + if (!candidate) continue + const link = parseContentPieceLink(candidate.url) + if (!link) continue + parsed.push({ link, candidate }) + } + + const catalogued = await cataloguedSourceUrls({ + payload, + actor, + identityUrls: parsed.flatMap(({ link }) => contentPiecePostIdentityUrls(link)), + }) + const candidates = parsed + .filter(({ link }) => + contentPiecePostIdentityUrls(link).every((identityUrl) => !catalogued.has(identityUrl)), + ) + .map(({ candidate }) => candidate) + + return { + found: parsed.length, + existingCount: parsed.length - candidates.length, + candidates, + } +} + +/** + * Creates one draft from one listed media, exactly in the paste path's shape: + * the C220 job then resolves the official file (or leaves the honest + * peça-link reason), transcribes and catalogues — the importer never + * downloads anything itself. `existing` covers the race with another actor + * (the identity probe lost) and the unique index is the last barrier. + */ +export const createContentPieceFromProfilePost = async ({ + payload, + actor, + url, + startJob = startContentPieceJobInBackground, +}: { + payload: Payload + actor: CampaignUser + url: string + startJob?: (contentPieceId: number) => void +}): Promise => { + const link = parseContentPieceLink(url) + if (!link || link.origin !== 'instagram') { + throw new Error(CONTENT_PIECE_LINK_INVALID_MESSAGE) + } + + if (await contentPieceExistsForPostIdentity({ payload, actor, link })) return 'existing' + + try { + const piece = await payload.create({ + collection: 'contentPiece', + data: { + title: contentPieceLinkTitle(link), + type: 'video', + origin: link.origin, + sourceUrl: link.canonicalUrl, + status: 'rascunho', + processingStatus: 'processando', + step: 'extraindo', + }, + depth: 0, + user: actor, + overrideAccess: false, + }) + startJob(piece.id) + return 'created' + } catch (error) { + // A concurrent import can still hit the unique index; it is the same + // duplicate the identity probe answers. + if (isContentPieceSourceUrlDuplicateError(error)) return 'existing' + throw error + } +} diff --git a/tests/int/contentPiece.int.spec.ts b/tests/int/contentPiece.int.spec.ts index 91b0215e..812cdafc 100644 --- a/tests/int/contentPiece.int.spec.ts +++ b/tests/int/contentPiece.int.spec.ts @@ -32,10 +32,14 @@ vi.mock('@/utilities/campaignAuth', () => ({ })) import { GET } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/arquivo/route' +import { POST as postProfileImportCreate } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/criar/route' +import { POST as postProfileImport } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route' import { addContentPieceByLinkForActor, + createContentPieceFromProfilePostForActor, deleteContentPieceForActor, getContentPieceStatusesForActor, + listContentPieceProfileImportCandidatesForActor, retryContentPieceForActor, searchContentPieceLeaderOptionsForActor, setContentPiecePublishedForActor, @@ -58,7 +62,10 @@ import { import { CONTENT_PIECE_FORBIDDEN_MESSAGE, CONTENT_PIECE_LINK_DUPLICATE_MESSAGE, + CONTENT_PIECE_LINK_INVALID_MESSAGE, CONTENT_PIECE_NOT_FOUND_MESSAGE, + CONTENT_PIECE_PROFILE_IMPORT_FEED_ERROR_MESSAGE, + CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE, CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE, } from '@/lib/schemas/contentPiece' import { slugify } from '@/lib/slug' @@ -81,6 +88,11 @@ import { resolveContentPieceSource, } from '@/utilities/content/contentPieceLink' import { loadContentPieceListPageData } from '@/utilities/content/contentPiecePageData' +import { + CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW, + createContentPieceFromProfilePost, + listContentPieceProfileImportCandidates, +} from '@/utilities/content/contentPieceProfileImport' import { getPublishedContentPieceBySlug, getPublishedContentPieceItems, @@ -1628,11 +1640,252 @@ describe('content pieces (C211)', () => { await expect(addContentPieceByLinkForActor({ url: `${url}?igsh=abc` })).rejects.toThrow( CONTENT_PIECE_LINK_DUPLICATE_MESSAGE, ) + // C230 — the identity is the post, not the URL spelling: the same + // shortcode under another kind is the same duplicate. + await expect( + addContentPieceByLinkForActor({ url: 'https://www.instagram.com/p/DUPLICADO/' }), + ).rejects.toThrow(CONTENT_PIECE_LINK_DUPLICATE_MESSAGE) await expect( addContentPieceByLinkForActor({ url: 'https://twitter.com/x/status/1' }), ).rejects.toThrow() }) + describe('C230 — import from the official profile', () => { + it('lists only the novelties and dedupes by post identity across URL kinds', async () => { + await setInstagramSettings(true) + const marker = Date.now().toString(36) + await createPiece({ + title: 'Peça já catalogada', + origin: 'instagram', + withMedia: false, + sourceUrl: `https://www.instagram.com/p/EX${marker}/`, + }) + + let receivedMaxResults: number | null = null + const listing = await listContentPieceProfileImportCandidates({ + payload, + actor: communicator, + loadFeed: async (args) => { + receivedMaxResults = args.maxResults + return { + username: 'depjorgesolla', + posts: [ + instagramPost({ permalink: `https://www.instagram.com/reel/NO${marker}/` }), + instagramPost({ + id: 'carrossel', + mediaType: 'CAROUSEL_ALBUM', + mediaUrl: null, + permalink: `https://www.instagram.com/p/CA${marker}/`, + }), + instagramPost({ + id: 'protegido', + mediaType: 'REEL', + mediaUrl: null, + permalink: `https://www.instagram.com/reel/PR${marker}/`, + }), + // The same post already in the Central, served under another kind. + instagramPost({ + id: 'existente', + permalink: `https://www.instagram.com/reel/EX${marker}/`, + }), + ], + } + }, + }) + + expect(receivedMaxResults).toBe(CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW) + expect(listing.found).toBe(4) + expect(listing.existingCount).toBe(1) + expect(listing.candidates).toEqual([ + { url: `https://www.instagram.com/reel/NO${marker}/`, linkOnlyReason: null }, + { url: `https://www.instagram.com/p/CA${marker}/`, linkOnlyReason: 'carrossel' }, + { url: `https://www.instagram.com/reel/PR${marker}/`, linkOnlyReason: 'indisponivel' }, + ]) + }) + + it('fails closed without the credential and never touches the feed', async () => { + await setInstagramSettings(false) + let called = false + + await expect( + listContentPieceProfileImportCandidates({ + payload, + actor: communicator, + loadFeed: async () => { + called = true + return { username: null, posts: [] } + }, + }), + ).rejects.toThrow(CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE) + expect(called).toBe(false) + }) + + it('maps a feed failure to the honest message', async () => { + await setInstagramSettings(true) + + await expect( + listContentPieceProfileImportCandidates({ + payload, + actor: communicator, + loadFeed: async () => { + throw new Error('Graph API fora do ar') + }, + }), + ).rejects.toThrow(CONTENT_PIECE_PROFILE_IMPORT_FEED_ERROR_MESSAGE) + }) + + it('creates one draft in the link shape and never duplicates on re-import', async () => { + await setInstagramSettings(true) + const marker = Date.now().toString(36) + const started: number[] = [] + + const outcome = await createContentPieceFromProfilePost({ + payload, + actor: communicator, + url: `https://www.instagram.com/reel/CR${marker}/`, + startJob: (id) => started.push(id), + }) + expect(outcome).toBe('created') + + const rows = await payload.find({ + collection: 'contentPiece', + where: { + sourceUrl: { + in: [ + `https://www.instagram.com/p/CR${marker}/`, + `https://www.instagram.com/reel/CR${marker}/`, + `https://www.instagram.com/tv/CR${marker}/`, + ], + }, + }, + depth: 0, + overrideAccess: true, + }) + expect(rows.totalDocs).toBe(1) + createdPieceIds.add(rows.docs[0]!.id) + expect(rows.docs[0]).toMatchObject({ + title: `Instagram · CR${marker}`, + type: 'video', + origin: 'instagram', + status: 'rascunho', + processingStatus: 'processando', + step: 'extraindo', + }) + expect(started).toEqual([rows.docs[0]!.id]) + + // The same post under another kind is the same identity: no twin, no job. + await expect( + createContentPieceFromProfilePost({ + payload, + actor: communicator, + url: `https://www.instagram.com/p/CR${marker}/`, + startJob: (id) => started.push(id), + }), + ).resolves.toBe('existing') + expect(started).toHaveLength(1) + }) + + it('refuses a non-Instagram URL in the profile creator', async () => { + await expect( + createContentPieceFromProfilePost({ + payload, + actor: communicator, + url: 'https://youtu.be/VIDEO1', + }), + ).rejects.toThrow(CONTENT_PIECE_LINK_INVALID_MESSAGE) + }) + + it('gates the actions behind the communication catalog', async () => { + await setInstagramSettings(true) + getCampaignUserMock.mockResolvedValue(leader) + + await expect(listContentPieceProfileImportCandidatesForActor()).rejects.toThrow( + CONTENT_PIECE_FORBIDDEN_MESSAGE, + ) + await expect( + createContentPieceFromProfilePostForActor({ url: 'https://www.instagram.com/reel/GATE1/' }), + ).rejects.toThrow(CONTENT_PIECE_FORBIDDEN_MESSAGE) + }) + + it('answers the JSON envelope of the two import routes', async () => { + getCampaignUserMock.mockResolvedValue(communicator) + + // Without the credential the listing route refuses with the safe message. + await setInstagramSettings(false) + const unconfigured = await postProfileImport( + new Request('http://localhost/campanha/comunicacao/conteudos/importar', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'http://localhost' }, + body: JSON.stringify({}), + }), + ) + expect(unconfigured.status).toBe(400) + await expect(unconfigured.json()).resolves.toEqual({ + status: 'error', + message: CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE, + }) + + await setInstagramSettings(true) + const marker = Date.now().toString(36) + const url = `https://www.instagram.com/reel/ROTA${marker}/` + const callCreate = () => + postProfileImportCreate( + new Request('http://localhost/campanha/comunicacao/conteudos/importar/criar', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'http://localhost' }, + body: JSON.stringify({ url }), + }), + ) + + const created = await callCreate() + expect(created.status).toBe(200) + await expect(created.json()).resolves.toEqual({ status: 'success', outcome: 'created' }) + const rows = await payload.find({ + collection: 'contentPiece', + where: { sourceUrl: { equals: url } }, + depth: 0, + overrideAccess: true, + }) + createdPieceIds.add(rows.docs[0]!.id) + await expect((await callCreate()).json()).resolves.toEqual({ + status: 'success', + outcome: 'existing', + }) + + const crossOrigin = await postProfileImportCreate( + new Request('http://localhost/campanha/comunicacao/conteudos/importar/criar', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'https://evil.example' }, + body: JSON.stringify({ url }), + }), + ) + expect(crossOrigin.status).toBe(403) + }) + + it('creates through the action without leaving the communication gate', async () => { + await setInstagramSettings(true) + const marker = Date.now().toString(36) + const url = `https://www.instagram.com/reel/ACT${marker}/` + getCampaignUserMock.mockResolvedValue(communicator) + + await expect(createContentPieceFromProfilePostForActor({ url })).resolves.toEqual({ + outcome: 'created', + }) + + const rows = await payload.find({ + collection: 'contentPiece', + where: { sourceUrl: { equals: url } }, + depth: 0, + overrideAccess: true, + }) + expect(rows.totalDocs).toBe(1) + createdPieceIds.add(rows.docs[0]!.id) + await expect(createContentPieceFromProfilePostForActor({ url })).resolves.toEqual({ + outcome: 'existing', + }) + }) + }) + it('filters and searches the list', async () => { const municipality = await payload.find({ collection: 'municipality', diff --git a/tests/unit/contentPiece.unit.spec.ts b/tests/unit/contentPiece.unit.spec.ts index e50ad657..598a375f 100644 --- a/tests/unit/contentPiece.unit.spec.ts +++ b/tests/unit/contentPiece.unit.spec.ts @@ -9,6 +9,8 @@ import { contentPieceLinkFailureReasonLabels, contentPieceLinkTitle, contentPieceOriginFromLink, + contentPiecePostIdentityUrls, + contentPieceProfileCandidateFromPost, contentPieceSearchText, contentPieceSlugBase, contentPieceSlugCandidates, @@ -238,6 +240,70 @@ describe('content piece links', () => { }) }) +describe('C230 — the identity and the import candidate of a profile post', () => { + it('spells every Instagram kind because the shortcode is the identity', () => { + const fromPost = parseContentPieceLink('https://www.instagram.com/p/ABC123/')! + const fromReel = parseContentPieceLink('https://www.instagram.com/reel/ABC123/')! + // The kind is presentation: both pastes probe the same three spellings. + expect(contentPiecePostIdentityUrls(fromPost)).toEqual([ + 'https://www.instagram.com/p/ABC123/', + 'https://www.instagram.com/reel/ABC123/', + 'https://www.instagram.com/tv/ABC123/', + ]) + expect(contentPiecePostIdentityUrls(fromReel)).toEqual(contentPiecePostIdentityUrls(fromPost)) + }) + + it('keeps the single canonical URL for YouTube', () => { + const youtube = parseContentPieceLink('https://youtu.be/VIDEO1')! + expect(contentPiecePostIdentityUrls(youtube)).toEqual([ + 'https://www.youtube.com/watch?v=VIDEO1', + ]) + }) + + it('classifies the feed metadata into the honest link-only reason', () => { + expect( + contentPieceProfileCandidateFromPost({ + permalink: 'https://www.instagram.com/reel/ABC/', + mediaType: 'REEL', + mediaUrl: 'https://cdn.example/reel.mp4', + }), + ).toEqual({ url: 'https://www.instagram.com/reel/ABC/', linkOnlyReason: null }) + expect( + contentPieceProfileCandidateFromPost({ + permalink: 'https://www.instagram.com/p/ABC/', + mediaType: 'IMAGE', + mediaUrl: 'https://cdn.example/photo.jpg', + }), + ).toEqual({ url: 'https://www.instagram.com/p/ABC/', linkOnlyReason: null }) + // A carousel never offers a single file, even when a child URL leaks in. + expect( + contentPieceProfileCandidateFromPost({ + permalink: 'https://www.instagram.com/p/ABC/', + mediaType: 'CAROUSEL_ALBUM', + mediaUrl: 'https://cdn.example/child.jpg', + }), + ).toEqual({ url: 'https://www.instagram.com/p/ABC/', linkOnlyReason: 'carrossel' }) + // Protected audio: the API omits the media URL. + expect( + contentPieceProfileCandidateFromPost({ + permalink: 'https://www.instagram.com/reel/ABC/', + mediaType: 'REEL', + mediaUrl: null, + }), + ).toEqual({ url: 'https://www.instagram.com/reel/ABC/', linkOnlyReason: 'indisponivel' }) + }) + + it('refuses a permalink the catalogue cannot parse', () => { + expect( + contentPieceProfileCandidateFromPost({ + permalink: 'https://www.instagram.com/stories/depjorgesolla/1/', + mediaType: 'IMAGE', + mediaUrl: 'https://cdn.example/photo.jpg', + }), + ).toBeNull() + }) +}) + describe('contentPieceSearchText', () => { it('normalizes title, description, transcript, topics, city and institution', () => { expect(