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.
+
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). */}
+
+
+
+ {CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE} A importação pelo perfil
+ fica indisponível até a credencial oficial existir. Não há atalho por scraping nesta
+ tela.
+
+
+ ) : 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 (
+
+ )
+}
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(