feat(C230): importar publicações do perfil oficial na Central

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
This commit is contained in:
Francisco Solla 2026-09-26 00:34:37 -03:00
parent 4bf0c0c0c6
commit 4a07bac596
16 changed files with 1486 additions and 38 deletions

View file

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

View file

@ -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<br/>lê o global → instagramConfigured (boolean)"] --> D["Dialog 'Importar do perfil'<br/>intro · running · recibo"]
D -->|"Fase A · POST .../conteudos/importar"| A["lista candidatos<br/>global + isInstagramFeedConfigured<br/>loadInstagramFeed maxResults 12"]
A -->|"sem credencial"| E1["erro seguro<br/>Instagram ainda não configurado"]
A -->|"feed lido"| DED["dedupe por identidade do post<br/>sourceUrl in variantes p · reel · tv"]
DED --> R["found · existingCount · candidates[]<br/>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<br/>rascunho · processando/extraindo"]
CR --> J["after() → runContentPieceJob<br/>re-resolve por shortcode · baixa mídia · cataloga"]
J --> OK["pronto com arquivo<br/>ou peça-link com linkFailureReason honesto"]
CR --> REC["recibo: novas / já estavam / falharam<br/>+ peça-link por motivo"]
EX --> REC
REC --> L["Ver os rascunhos<br/>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.

View file

@ -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',

View file

@ -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<ContentPieceProfileImportCreateResponse>({
status: 'success',
outcome: result.outcome,
})
},
)

View file

@ -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<ContentPieceProfileImportCandidatesResponse>({
status: 'success',
...listing,
})
},
)

View file

@ -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
<CampaignListEmptyState
icon={PackageOpenIcon}
title="Nenhuma peça na Central ainda"
description="Envie os arquivos do evento ou cole o link de uma publicação do Instagram/YouTube. Cada peça entra como rascunho e sai catalogada."
description="Envie os arquivos do evento, importe as publicações recentes de @depjorgesolla ou cole o link de uma publicação do Instagram/YouTube. Cada peça entra como rascunho."
contentClassName="max-w-none"
>
<div className="flex flex-wrap items-center justify-center gap-2">
<ContentPieceUploadDialog />
<ImportContentPieceProfileDialog configured={instagramConfigured} />
<AddContentPieceLinkDialog />
</div>
<p className="text-xs text-muted-foreground">
Importar usa somente o perfil oficial e nunca publica automaticamente.
</p>
</CampaignListEmptyState>
)
@ -77,27 +87,40 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP
<CampaignPageShell aria-label="Central de Conteúdos">
<ContentPieceStatusRefresher pieces={pendingPieces} />
<CampaignListPendingBoundary>
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3 sm:mb-6">
<div className="min-w-0">
<h2 className="text-lg font-semibold">Conteúdos</h2>
<p className="mt-1 text-sm text-muted-foreground">
Peças que alimentam a Central pública. Rascunhos não aparecem no site.
</p>
</div>
{/* 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. */}
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row sm:items-center">
<AddContentPieceLinkDialog
triggerLabel={
<>
<span className="max-sm:hidden">Adicionar por link</span>
<span className="sm:hidden">Link</span>
</>
}
triggerClassName="max-sm:w-full"
/>
<ContentPieceUploadDialog 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). */}
<div className="flex w-full flex-col gap-2 sm:ms-auto sm:w-auto sm:flex-row sm:items-center">
<ContentPieceUploadDialog triggerClassName="max-sm:order-first max-sm:w-full sm:order-last" />
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center">
<div className="flex flex-col items-stretch max-sm:order-last sm:items-end">
<ImportContentPieceProfileDialog
configured={instagramConfigured}
triggerClassName="max-sm:w-full"
/>
{!instagramConfigured ? (
<p className="mt-1.5 text-xs font-medium text-muted-foreground max-sm:hidden">
{CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE}
</p>
) : null}
</div>
<AddContentPieceLinkDialog
triggerLabel={
<>
<span className="max-sm:hidden">Adicionar por link</span>
<span className="sm:hidden">Adicionar link</span>
</>
}
triggerClassName="max-sm:w-full"
/>
</div>
</div>
</div>
@ -108,6 +131,17 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP
}
/>
{!instagramConfigured ? (
<div className="mt-5 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs leading-5 text-amber-900">
<CircleAlertIcon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<span>
<b>{CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE}</b> A importação pelo perfil
fica indisponível até a credencial oficial existir. Não há atalho por scraping nesta
tela.
</span>
</div>
) : null}
<CampaignListResults>
<ContentPieceCardList rows={data.rows} empty={emptyState} />
<ContentPieceTable

View file

@ -1,4 +1,4 @@
import type { ContentPieceViewModel } from '@/lib/contentPiece'
import type { ContentPieceProfileCandidate, ContentPieceViewModel } from '@/lib/contentPiece'
/** Wire contract of `POST .../conteudos/enviar` (C211) — raw-body upload. */
export type ContentPieceUploadResponse =
@ -10,6 +10,21 @@ export type ContentPieceLinkResponse =
| { status: 'success'; piece: ContentPieceViewModel }
| { status: 'error'; message: string }
/** Wire contract of `POST .../conteudos/importar` (C230) — profile listing. */
export type ContentPieceProfileImportCandidatesResponse =
| {
status: 'success'
found: number
existingCount: number
candidates: ContentPieceProfileCandidate[]
}
| { status: 'error'; message: string }
/** Wire contract of `POST .../conteudos/importar/criar` (C230) — one draft. */
export type ContentPieceProfileImportCreateResponse =
| { status: 'success'; outcome: 'created' | 'existing' }
| { status: 'error'; message: string }
/** Wire contract of `POST .../conteudos/status` (C211) — status poll. */
export type ContentPieceStatusResponse =
| { status: 'success'; pieces: ContentPieceViewModel[] }

View file

@ -36,6 +36,16 @@ import {
searchContentPieceLeaderOptions,
type ContentPieceLeaderOption,
} from '@/utilities/content/contentPieceLeaderOptions'
import {
contentPieceExistsForPostIdentity,
isContentPieceSourceUrlDuplicateError,
} from '@/utilities/content/contentPieceLink'
import {
createContentPieceFromProfilePost,
listContentPieceProfileImportCandidates,
type ContentPieceProfileImportListing,
type ContentPieceProfileImportOutcome,
} from '@/utilities/content/contentPieceProfileImport'
import { startContentPieceJobInBackground } from '@/utilities/content/contentPieceScheduler'
import { onPayloadTransactionCommit, withPayloadTransaction } from '@/utilities/payloadTransaction'
@ -332,16 +342,11 @@ export const addContentPieceByLinkForActor = async (input: {
const link = parseContentPieceLink(parsed.url)
if (!link) throw new Error(CONTENT_PIECE_LINK_INVALID_MESSAGE)
const existing = await payload.find({
collection: 'contentPiece',
where: { sourceUrl: { equals: link.canonicalUrl } },
depth: 0,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
})
if (existing.docs[0]) throw new Error(CONTENT_PIECE_LINK_DUPLICATE_MESSAGE)
// The post's identity, not the URL spelling: pasting `/p/ABC/` answers the
// same duplicate as `/reel/ABC/` (C230 shares this probe with the importer).
if (await contentPieceExistsForPostIdentity({ payload, actor, link })) {
throw new Error(CONTENT_PIECE_LINK_DUPLICATE_MESSAGE)
}
try {
// A link is catalogued as a video piece; the extraction reclassifies an
@ -367,16 +372,45 @@ export const addContentPieceByLinkForActor = async (input: {
} catch (error) {
// A concurrent paste can still hit the unique index; it is the same
// duplicate the probe above answers, so it maps to the same message.
if (
error instanceof Error &&
/source_url|sourceUrl|duplicate key|unique/i.test(error.message)
) {
if (isContentPieceSourceUrlDuplicateError(error)) {
throw new Error(CONTENT_PIECE_LINK_DUPLICATE_MESSAGE)
}
throw error
}
}
/**
* C230 — lists the novelties of the official profile: the feed is read with
* the global's credential (fail-closed) and each post is checked against the
* Central by the identity of the post, never by the URL spelling. The listing
* persists nothing; the confirmation creates the pieces one by one.
*/
export const listContentPieceProfileImportCandidatesForActor =
async (): Promise<ContentPieceProfileImportListing> => {
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

View file

@ -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<ContentPieceProfileLinkOnlyReason, number>()
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<ImportPhase>('intro')
const [listing, setListing] = useState(false)
const [progress, setProgress] = useState({ done: 0, total: 0 })
const [result, setResult] = useState<ImportResult | null>(null)
const [error, setError] = useState<string | null>(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<ContentPieceProfileImportCandidatesResponse>(
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<ContentPieceProfileImportCreateResponse>(
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 (
<Dialog
open={open}
onOpenChange={(next) => {
if (phase === 'running') return
setOpen(next)
if (!next) reset()
}}
>
<DialogTrigger asChild>
<Button
variant="outline"
className={cn('min-h-11', triggerClassName)}
disabled={!configured}
>
<InstagramIcon data-icon="inline-start" aria-hidden="true" />
<span className="max-sm:hidden">Importar do perfil</span>
<span className="sm:hidden">Importar perfil</span>
</Button>
</DialogTrigger>
<DialogContent
className="sm:max-w-lg"
showCloseButton={phase !== 'running'}
onInteractOutside={(event) => {
if (phase === 'running') event.preventDefault()
}}
onEscapeKeyDown={(event) => {
if (phase === 'running') event.preventDefault()
}}
>
{phase === 'done' && result ? (
<>
<DialogHeader>
<div className="mb-4 grid size-10 place-items-center rounded-full bg-green-100 text-green-700">
<CheckIcon className="size-5" aria-hidden="true" />
</div>
<DialogTitle className="border-b-0 pb-0">Importação concluída</DialogTitle>
<DialogDescription>
{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.'}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
<b className="block text-xl tabular-nums text-green-800">{result.created}</b>
<span className="text-xs font-medium text-green-800">novas criadas</span>
</div>
<div className="rounded-lg border bg-stone-50 p-3">
<b className="block text-xl tabular-nums">{result.existing}</b>
<span className="text-xs font-medium text-muted-foreground">já estavam</span>
</div>
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
<b className="block text-xl tabular-nums text-red-800">{result.failed}</b>
<span className="text-xs font-medium text-red-800">
{result.failed === 1 ? 'falhou' : 'falharam'}
</span>
</div>
</div>
{linkOnlyRows.length > 0 || result.failed > 0 ? (
<div className="overflow-hidden rounded-lg border text-xs">
{linkOnlyRows.map(([reason, count], index) => {
const status = IMPORT_STATUS[reason]
return (
<div
key={reason}
className={cn(
'flex items-start gap-3 p-3.5',
index < linkOnlyRows.length - 1 || result.failed > 0 ? 'border-b' : '',
)}
>
<Badge variant="secondary" className="shrink-0">
Peça-link
</Badge>
<div>
<b className="text-sm">
{count} {status.title} · criado como peça-link
</b>
<p className="mt-1 leading-5 text-muted-foreground">{status.description}</p>
</div>
</div>
)
})}
{result.failed > 0 ? (
<div className="flex items-start gap-3 bg-red-50 p-3.5">
<CircleAlertIcon
className="mt-0.5 size-4 shrink-0 text-red-700"
aria-hidden="true"
/>
<div>
<b className="text-sm text-red-900">
{result.failed === 1
? '1 publicação não foi importada'
: `${result.failed} publicações não foram importadas`}
</b>
<p className="mt-1 leading-5 text-red-800">
{result.failureMessage ?? 'Falha de rede ao preparar a peça.'} Tente
importar novamente; as peças já criadas não serão duplicadas.
</p>
</div>
</div>
) : null}
</div>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => {
setOpen(false)
reset()
}}
>
Fechar
</Button>
<Button asChild className="min-h-11">
<Link
href={buildContentPieceListHref({ page: 1, statuses: ['rascunho'] }, 1)}
onClick={() => {
setOpen(false)
reset()
}}
>
Ver os rascunhos
</Link>
</Button>
</DialogFooter>
</>
) : phase === 'running' ? (
<>
<DialogHeader>
<div className="flex items-center gap-3">
<Spinner className="size-5 shrink-0" />
<div>
<DialogTitle className="border-b-0 pb-0">Importando do perfil</DialogTitle>
<DialogDescription>Mantenha esta janela aberta até concluir.</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="rounded-xl border p-4">
<div className="flex items-start gap-3">
<div
className={cn(
'mt-0.5 grid size-6 shrink-0 place-items-center rounded-full',
listing ? 'bg-stone-100 text-stone-500' : 'bg-green-100 text-green-700',
)}
>
{listing ? (
<Spinner className="size-3.5" />
) : (
<CheckIcon className="size-3.5" aria-hidden="true" />
)}
</div>
<div>
<b className="text-sm">
{listing
? 'Buscando as publicações recentes…'
: 'Publicações recentes encontradas'}
</b>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{listing
? `Consultando ${PROFILE_HANDLE} pela API oficial.`
: `A busca foi feita em ${PROFILE_HANDLE} pela API oficial.`}
</p>
</div>
</div>
{!listing && progress.total > 0 ? (
<>
<div className="my-3 ml-3 h-5 border-l border-dashed" />
<div className="flex items-start gap-3">
<Spinner className="mt-0.5 shrink-0" />
<div>
<b className="text-sm">
Importando {Math.min(progress.done + 1, progress.total)} de {progress.total}
…
</b>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Criando apenas as novidades como rascunho e reconhecendo o que já estava na
Central.
</p>
</div>
</div>
</>
) : null}
</div>
<p className="rounded-lg bg-muted/70 p-3 text-xs leading-5 text-muted-foreground">
Algumas mídias podem levar mais tempo para serem preparadas. O resultado final separa
novas, já existentes e falhas — nenhuma falha fica escondida.
</p>
<DialogFooter>
<Button type="button" variant="secondary" className="min-h-11" disabled>
<Spinner data-icon="inline-start" aria-hidden="true" />
Importando…
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<div className="mb-4 grid size-10 place-items-center rounded-full bg-red-50 text-primary">
<InstagramIcon className="size-5" aria-hidden="true" />
</div>
<DialogTitle className="border-b-0 pb-0">Importar do perfil</DialogTitle>
<DialogDescription>
Vamos procurar as publicações recentes de{' '}
<b className="text-foreground">{PROFILE_HANDLE}</b> que ainda não estão na Central.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="space-y-3">
<div className="flex gap-3 rounded-lg border p-3.5">
<CheckIcon className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden="true" />
<div>
<b className="text-sm">Somente novidades da janela recente</b>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
Publicações que já estão na Central serão reconhecidas e não criam cópias.
</p>
</div>
</div>
<div className="flex gap-3 rounded-lg border p-3.5">
<CheckIcon className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden="true" />
<div>
<b className="text-sm">Tudo entra como Rascunho</b>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
Nada é publicado automaticamente. A assessoria revisa e decide o que publicar.
</p>
</div>
</div>
</div>
<div className="rounded-lg border bg-stone-50 p-4 text-xs leading-5 text-muted-foreground">
<b className="text-foreground">Sem scraping.</b>
<br />
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.
</div>
{error ? (
<Alert variant="destructive" className="py-2" role="alert">
<CircleAlertIcon aria-hidden="true" />
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => {
setOpen(false)
reset()
}}
>
Cancelar
</Button>
<Button type="button" className="min-h-11" onClick={() => void runImport()}>
Buscar publicações
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}

View file

@ -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' : ''}`

View file

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

View file

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

View file

@ -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<boolean> => {
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

View file

@ -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<Response>
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<Set<string>> => {
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<boolean> => {
// 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<Awaited<ReturnType<FeedLoader>>> => {
// 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<ReturnType<FeedLoader>>
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<ContentPieceProfileImportListing> => {
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<ContentPieceProfileImportOutcome> => {
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
}
}

View file

@ -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',

View file

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