feat(C235): importa qualquer janela de publicações do perfil na Central

A importação do perfil oficial deixa a janela fixa de 12 mídias e ganha o recorte por período (desde; até opcional), com prévia honesta (novas · já estavam · total) e confirmação antes de criar; "Recentes" segue o default e o clique único. O planner puro de janela/dedupe passa ao dono em src/lib (CLI re-exporta) e a varredura usa o teto de páginas derivado de maxResults, com early-stop por data e corte denunciado (truncated). Sem migration/Consent; tudo Rascunho; fail-closed de credencial intacto.

Design tier: DEGRADED (central-conteudos-importar-janela-ui-design) — port classe-a-classes das cenas 01–08 com sign-off humano explícito (2026-09-29); a crítica de paridade do designer no tier primário não pôde rodar por quota.
This commit is contained in:
Francisco Solla 2026-09-29 12:11:04 -03:00
parent fa1bf52ccd
commit 8608aeb317
17 changed files with 1696 additions and 368 deletions

View file

@ -0,0 +1 @@
**C235 — Central de Conteúdos: importar qualquer janela de publicações do perfil (2026-09-29):** a importação do perfil oficial (`@depjorgesolla`) deixa de ter só a janela fixa de 12 mídias recentes (C230) e passa a aceitar **qualquer período** — "Recentes" continua o default e o clique único, e "Período" (desde; até opcional, por data de publicação, com atalhos de 30 dias/6 meses/este ano) é gesto explícito que mostra o tamanho honesto antes de criar (**novas · já estavam · total na janela**) e confirma ("Importar N publicações"), com aviso âmbar de "Janela grande" acima de 50 novidades. O recibo continua honesto (**novas / já estavam / falharam com motivo**, peça-link com o porquê) e ganha o aviso de corte quando a via oficial não alcança o início da janela ("o Instagram entrega as 10K mídias mais recentes"; reexecutar continua sem duplicar). Mecânica sem teto artificial: o planner puro de janela/dedupe que vivia no CLI (`scripts/lib/instagramContentPlan.mjs`) passou ao dono em `src/lib/contentPieceProfileWindow.ts` (CLI re-exporta os nomes antigos, pins intocados) e a varredura usa o cursor do feed com **early-stop por data** — o teto defensivo de páginas do `loadInstagramFeed` agora deriva de `maxResults` (piso 20 intacto para board/sync/lookup; a janela de 10K sobe a 200 páginas) e o corte é denunciado por cobertura (`truncated`), nunca em silêncio. Limites civis da Bahia: as datas do formulário viram o início do dia `since` e o fim exclusivo do dia `until` (novos `formatCivilDateLabel`/`subtractBahiaCivilMonths` em `campaignTime.ts`); o request vira união (`{}`/`recent` default | `period` com `since`/`until`), com validação pura (data real, ordem, não-futuro) no schema e no cliente, e o lookup de identidade (`sourceUrl in` nas 3 grafias do post) roda em chunks de 2.000 URLs. Nada de novo em schema/Consent/collection (sem migration); o token segue só no servidor, fail-closed de credencial nas duas pontas e tudo entra como Rascunho. Testes: unit novo do módulo puro (janela com/sem bounds, bounds civis, validação, cobertura, label), pin novo do teto de páginas derivado e do CLI por re-export; int com `loadFeed` fake (período com `shouldStopAt` no limite, fora-da-janela fora, dedupe por variante de kind, `truncated` true/false, recente default 12, período impossível antes do feed, envelope da rota com `{}`/período/inválido). Verificação: `pnpm gate:fast` (451 arquivos, 4984 unit verdes), `pnpm test:int` (109 arquivos, 1068 verdes), lint/typecheck. **Design gate: artefato DEGRADED** (`docs/plans/central-conteudos-importar-janela-ui-design.html`, cenas 01–08 portadas classe-a-classe); a crítica de paridade do `designer` no tier primário (triggers b/c) não pôde rodar nesta sessão por quota (`usage limit reached`) e o humano deu **sign-off explícito** do artefato e do port (2026-09-29), registrado no PR com `Design tier: DEGRADED`.

View file

@ -0,0 +1,180 @@
# Impl: C235 — Central de Conteúdos — importar qualquer janela de publicações do perfil
Status: aprovado
Atualizado em: 2026-09-29
Issue: #1386
Intenção: docs/plans/central-conteudos-importar-janela.md
Appetite restante: herdado (~1–2 dias eng) — sem corte novo: a fatia reusa o cliente do feed, o pipeline C220, o dedupe por identidade e o diálogo C230; backfill total, cursor persistido, agendador e triagem prévia seguem fora (intenção).
> Modo `--auto` (work-issue): plano nasce aprovado pelo agente. Design UI do gate: `docs/plans/central-conteudos-importar-janela-ui-design.html` — **DEGRADED** (`:7`, `:261-266`, tier fallback; cenas 01–08 cobrem o fluxo).
>
> **Registro de execução (2026-09-29):** o dispatch do `designer` primário no trigger (b) foi tentado no início da execução e **falhou por quota** (`usage limit reached`); o port foi feito contra o artefato DEGRADED como referência (precedente S16/S18/S34/S40: artefato degradado + crítica de fechamento no tier primário), sem improvisar estrutura visual — todos os estados do fluxo (janela, prévia, progresso, recibo, vazio, falha, corte, mobile) estão nas cenas 01–08. A certificação de fechamento (trigger c) e o sign-off humano continuam obrigatórios: **sem tier primário não há PR** — o fluxo `--auto` para antes do `pnpm push`, comenta a Issue e flipa para `blocked` (D6).
>
> **Sign-off humano (2026-09-29):** o humano aprovou explicitamente o artefato DEGRADED e o port nesta sessão; a entrega segue para PR **Ready** com `Design tier: DEGRADED (<slug>)` e o registro do sign-off no body, conforme o caminho de `work-issue` com humano presente para `DEGRADED`.
## Leitura da intenção
- **Outcome:** com a credencial configurada, o operador escolhe a janela da importação do perfil oficial — **recentes** (default, como hoje) ou **período** (desde; até opcional) — vê o tamanho antes de criar em janelas de período (novas · já estavam · total), confirma, e lê o recibo honesto **novas / já estavam / falharam com motivo**; a execução cobre a janela pela via oficial sem teto artificial e, quando a API/limite corta, o recibo diz; dedupe por identidade do post segue soberano (reexecução e janelas sobrepostas não duplicam); tudo entra como Rascunho, nada é publicado.
- **O que NÃO negociar:**
- Só o perfil próprio (`@depjorgesolla`) pela API oficial (`loadInstagramFeed`, `src/utilities/socialFeed/instagramFeed.ts:253`); sem scraping, terceiro ou stories; `instagramFeed.ts` não importa `@payload-config` (gate de ciclos madge).
- Dedupe por identidade do post — `contentPiecePostIdentityUrls` (`src/lib/contentPiece.ts:487`) e `contentPieceExistsForPostIdentity` (`src/utilities/content/contentPieceLink.ts:93`) — sem segundo resolvedor, segunda fila ou segunda identidade.
- Fail-closed de credencial nas duas pontas ("Instagram ainda não configurado."); token nunca em log/URL/wire; erro de feed vira mensagem de produto.
- Tudo Rascunho; nenhuma publicação automática; `curatedFields` preservado; board/feed da home intocados.
- Gate de comunicação `canReadCommunicationCatalog`; `user` + `overrideAccess:false` em todo caminho de request.
- Sem migration/collection/Consent novo (nenhum campo/global muda; `push:false` intacto); sem escrita multi-collection nova.
- Design **DEGRADED** não autoriza markup: certificação/estouro do designer primário antes da fase 2; sign-off humano antes do push (D6).
- **O que reavaliar (hipóteses da intenção, resolvidas no código):**
- "`since`/`until` da edge resolvem a janela" — **rejeitada**: documentado mas não provado no host `graph.instagram.com` e sem teste offline; o caminho provado no repo é cursor + `shouldStopAt` (C220/C230/CLI) — D1.
- "o teto de 20 páginas do feed comporta a janela" — **não**: `INSTAGRAM_MAX_MEDIA_PAGES = 20` (`instagramFeed.ts:156`) = 1000 posts; cortaria janelas antigas. Vira parâmetro opcional (default intacto) e o importer usa o próprio limite da edge — D1.
- "a lógica pura de janela não existe" — **existe** em `scripts/lib/instagramContentPlan.mjs:120` (`planInstagramContentWindow`), e o CLI já faz early-stop por data (`scripts/import-instagram-content.mjs:201-204`); o plano move o dono e o CLI delega — D2.
- "o request de listagem é `z.object({})`" (`src/lib/schemas/contentPiece.ts:89`) — vira união com default `recent` para não quebrar o envelope pinado C230 — D3.
- "o diálogo C230 cria direto após a busca" (`ImportContentPieceProfileDialog.tsx:107-169`) — período ganha prévia/confirmação; recentes mantém clique único — D4.
## Abordagem recomendada
```mermaid
flowchart LR
P["Central de Conteúdos<br/>page.tsx intocada"] --> D["Dialog<br/>recentes (default) · período"]
D -->|"recentes · clique único"| A["POST .../conteudos/importar<br/>{} ou {mode:'recent'}"]
D -->|"período · prévia"| B["POST .../conteudos/importar<br/>{mode:'period', since, until?}"]
A --> F["contentPieceProfileImport<br/>global fail-closed · loadInstagramFeed"]
B --> G["scan por cursor + shouldStopAt<br/>teto de páginas derivado do maxResults (10K) · timeout 120s"]
F --> H["planner puro (src/lib)<br/>janela · dedupe por identidade"]
G --> H
H --> R["found · existingCount · candidates · truncated"]
R -->|"período"| K["confirmação: novas · já estavam · total<br/>aviso 'Janela grande' > 50"]
K -->|"Importar N publicações"| L["loop 1 POST por candidato<br/>.../importar/criar (C230 intacto)"]
R -->|"recentes"| L
L --> REC["recibo honesto<br/>+ aviso de corte quando truncated"]
```
**Opções consideradas (geral):** A) janela por cursor walk + `shouldStopAt` com teto parametrizado no cliente do feed, planner puro movido para `src/lib` (sem twinning) e fluxo de diálogo com prévia/confirmação; B) `since`/`until` nativos da Graph API; C) segunda implementação de janela/dedupe dentro do importador com fila/estado próprio.
**Recomendação:** **A** — um dono por mecanismo (feed, planner, identidade, pipeline), zero schema novo, testável offline com `fetchImpl`/`loadFeed` injetados e cabe no appetite.
**Rejeitadas:** **B** — não testável offline, comportamento real desconhecido no host e contraria o caminho provado; **C** — twinning do dedupe/janela e fila fora de escopo (C212 §Q4).
### D1 — Mecânica da varredura: cursor + early-stop, teto parametrizado e corte denunciado por cobertura
**Opções:** A) manter o cursor walk do feed com `shouldStopAt` por timestamp (o mesmo que C220 e o CLI provam), derivar o teto defensivo de páginas do próprio `maxResults` (piso 20 intacto; a janela de 10K sobe o teto para 200 páginas), denunciando corte por **cobertura** (`truncated` quando o feed não é vazio e nenhum post retornado é mais antigo que o `since` pedido); B) `since`/`until` nativos da edge `/{ig-user-id}/media`; C) híbrido (manda `since`/`until` e também pagina com cursor).
**Recomendação:** **A** — é o único caminho exercitado e testável sem rede (`fetchImpl` fake pino o walk em `tests/unit/instagramFeed.unit.spec.ts:342-396`); o early-stop para no primeiro post mais antigo que `since` (a página inteira ainda entra, o planner descarta o excedente) e a cobertura cobre os três cortes possíveis — teto do cliente, fim de paginação da API (limite de 10K) e fatia de `maxResults`. `truncated` é inferido no importer (`posts.length > 0 && !covered`), sem novo estado no feed. O early-stop só é composto para período; recentes segue sem ele.
**Rejeitadas:** **B** — sem teste offline, o comportamento real de `limit`+cursor no host graph.instagram.com é pergunta aberta (`docs/plans/central-conteudos-varredura-instagram.md:151`) e o cliente do feed é dono do cursor; **C** — dois mecanismos para o mesmo corte = drift e nenhum ganho.
### D2 — Dono do planner puro: mover para `src/lib`, CLI delega (sem twinning)
**Opções:** A) mover `planInstagramContentWindow` (`scripts/lib/instagramContentPlan.mjs:120`) e `instagramContentFeedIdentityUrls` (`:92`) para `src/lib/contentPieceProfileWindow.ts` (puro, client-safe), o mjs re-exportar com os nomes antigos e o importador C230 passar a usar o mesmo planner para recentes e período (bounds nulos = sem filtro de data); B) o servidor importar o `.mjs` de `scripts/`; C) reimplementar o filtro dentro de `contentPieceProfileImport.ts`.
**Recomendação:** **A** — depth check: a política de janela/dedupe já existe e tem dono; mover para `src/lib` deixa o CLI como shell (parse/report ficam) e o servidor reusa a mesma contagem honesta (`found` = candidatos + já existentes + duplicatas do feed; `outsideWindow`/`malformed` ficam no plano, não no wire). Mantém o shape de retorno do plano idêntico para os pins do CLI (`tests/unit/instagramContentPlan.unit.spec.ts:88-144`); no modo recentes o planner roda sem bounds e a semântica dos pins C230 (`found`/`existingCount`/`candidates`) permanece.
**Rejeitadas:** **B** — app/Server Action importando `scripts/**` é superfície errada (build/knip/`server-only`); **C** — vira segunda identidade de dedupe/janela no mesmo módulo (o defeito que o C230 resolveu).
### D3 — Contrato do request/rota e datas civis
**Opções:** A) `contentPieceProfileImportRequestSchema` (`src/lib/schemas/contentPiece.ts:89`) vira união: `{}`/`{mode:'recent'}` (default) | `{mode:'period', since:'YYYY-MM-DD', until?:'YYYY-MM-DD'}` com refine puro (forma, data real, `until ≥ since`, `since ≤ hoje` BRT); o range civil vira instantes com `parseBahiaDateTimeInput` (`src/lib/campaignTime.ts:83`) — início do dia `T00:00`, fim do dia `T23:59` + 59,999 s, `until` ausente = agora; a action re-parseia (precedente C230) e o diálogo valida antes com o mesmo helper para copy/desabilitar; B) `mode` obrigatório; C) `since`/`until` como instantes ISO no wire.
**Recomendação:** **A** — o default do produto continua um body vazio (o pin do envelope C230 em `tests/int/contentPiece.int.spec.ts:1852-1905` segue verde), `<input type="date">` fala `YYYY-MM-DD` e o operador pensa em dia civil da Bahia, não em instante. Sem nova safe message: período inválido cai no genérico (o diálogo impede antes; o servidor é defesa em profundidade). `criar/route.ts` e `contentPieceLinkRequestSchema` intocados.
**Rejeitadas:** **B** — quebra o pin `{}` e o default sem ganho; **C** — instante no wire empurra conversão de fuso para o cliente e ignora o input nativo.
### D4 — Fluxo do diálogo: recentes clique único; período prévia + confirmação
**Opções:** A) recentes mantém o clique único de hoje (lista e cria no mesmo gesto, janela 12); período ganha prévia com contagens ("novas a criar · já estavam · total na janela"), aviso âmbar "Janela grande" quando `candidates > CONTENT_PIECE_PROFILE_IMPORT_LARGE_WINDOW_THRESHOLD` (50 = uma página do feed), botões "Importar N publicações"/"Trocar"/"Voltar", e os estados da cena 06 (vazio, falha com "Tentar de novo", corte no recibo); B) período cria direto; C) prévia para tudo, inclusive recentes.
**Recomendação:** **A** — o recorte por período é gesto explícito e o design fixa o tamanho antes de criar (cenas 02/03); a operação diária não muda; `truncated` aparece no recibo (cena 06d) e um período sem candidatos nem vira botão "Importar 0" (receita cena 05 adaptada com "Nenhuma novidade na janela"). O limiar de "janela grande" vive como constante no módulo puro (client-safe), revisável por gatilho.
**Rejeitadas:** **B** — despejo acidental de rascunhos que o produto quer evitar; **C** — fricção nova no fluxo diário.
### D5 — Tempo limite e feedback da busca
**Opções:** A) recentes mantém 30 s (`CONTENT_PIECE_PROFILE_IMPORT_FEED_TIMEOUT_MS`, `contentPieceProfileImport.ts:43`); período ganha orçamento maior (120 s) para o walk de muitas páginas; spinner sem progresso de páginas; B) timeout único alto para os dois modos; C) progresso por página/streaming.
**Recomendação:** **A** — `AbortSignal.timeout` já cobre todas as chamadas do walk e o período pode custar dezenas de fetches; estourou, cai na mensagem honesta de retry existente e nada foi criado (a criação só começa depois da prévia). A UI não conhece o total de páginas — progresso de busca seria inventado; o "pode demorar" do design é o feedback.
**Rejeitadas:** **B** — uma API pendurada seguraria o recente por 2 min sem motivo; **C** — progresso inventado (não há total antes de varrer).
### D6 — Design gate: DEGRADED ⇒ certificação antes do markup e sign-off antes do push
**Opções:** A) tratar o artefato como gate duro: o designer primário certifica/estende as cenas **antes** de qualquer markup da fase 2; a crítica final certificada + sign-off humano ficam registrados no PR (`Design tier:`), e o DEGRADED sozinho **não** vai ao push; B) seguir o DEGRADED como aprovado; C) redesenhar do zero.
**Recomendação:** **A** — a doutrina do repo só permite port classe-a-classe sob artefato certificado; DEGRADED é explicitamente "design NÃO certificado; requer sign-off humano" (`design html:261-266`). **Execução:** o designer primário estava indisponível (quota) no trigger (b); o port seguiu o artefato como referência (precedente S16/S18/S34/S40) e a certificação fica no fechamento (trigger c) — se o tier primário não certificar, o fluxo para antes do push (sem PR; Issue `blocked`). Estados não desenhados que a execução encontrar → designer estende antes do markup.
**Rejeitadas:** **B** — viola a disciplina de design; **C** — as cenas 01–08 já cobrem o fluxo; estender é mais barato.
### D7 — Testes: unit do puro + int com deps injetadas; e2e fica fora
**Opções:** A) unit do módulo puro (janela com/sem bounds, cobertura/`truncated`, bounds civis, validação, limiar) e pin novo do teto derivado de `maxResults` no feed; int do `contentPieceProfileImport` com `loadFeed` fake (recent/period, `shouldStopAt` no limite, `truncated`, dedupe por identidade, fail-closed, erro honesto) e envelope da rota; e2e sem spec nova; B) e2e novo com stub do Instagram; C) só int.
**Recomendação:** **A** — mesma razão do C230: a fronteira é coberta com dep injetada, sem credencial real e sem estado compartilhado do `social-feed-settings` entre projetos; pins C230/C220/CLI seguem verdes e o diff acorda `campaignSpeechAcervo` pelo manifesto (`src/lib/schemas/contentPiece.ts` é risk prefix mapeado, `:575-577`; `src/components/campaign/content` e `src/utilities/content`, `:552-558`; o arquivo novo casa o prefixo `src/lib/contentPiece`, `:574`).
**Rejeitadas:** **B** — flake e custo sem cobrir o que o int cobre; **C** — a política de janela (a parte mais fácil de errar) ficaria sem pin barato.
### Componentes / mudanças
- **`src/lib/contentPieceProfileWindow.ts`** (novo, puro, client-safe; não importa `utilities/`): dono do recorte. Move `planContentPieceProfileWindow` (de `instagramContentPlan.mjs:120`; `from`/`to` opcionais/null = sem filtro) e `contentPieceProfileFeedIdentityUrls` (`:92`); tipos `ContentPieceProfileWindowInput` (`{mode:'recent'} | {mode:'period'; since; until?}`); `contentPieceProfilePeriodError({since, until, today})` (forma `YYYY-MM-DD`, data real por round-trip `formatBahiaCivilDate`, ordem, `since` não-futuro); `contentPieceProfilePeriodBounds({window, now})` → `{fromIso, toIso}` compondo `parseBahiaDateTimeInput` (`campaignTime.ts:83`); `contentPieceProfileWindowCovered({posts, fromMs})`; `contentPieceProfileWindowLabel` (compõe `formatCivilDateLabel`, `campaignTime.ts`, para "01/08/2026 → hoje"); `CONTENT_PIECE_PROFILE_IMPORT_LARGE_WINDOW_THRESHOLD = 50`.
- **`src/lib/campaignTime.ts`**: ganha `formatCivilDateLabel` (`aaaa-mm-dd` → `dd/mm/aaaa`) e `subtractBahiaCivilMonths` (clamp de fim de mês) — donos puros dos atalhos de janela do diálogo.
- **`scripts/lib/instagramContentPlan.mjs`**: passa a importar e re-exportar o planner/identidades com os nomes antigos (`planInstagramContentWindow`, `instagramContentFeedIdentityUrls`) — CLI (`import-instagram-content.mjs:55/236`), unit (`instagramContentPlan.unit.spec.ts`) e guards (`instagramContentImportCli.unit.spec.ts`) intocados. Args/report/summary ficam.
- **`src/utilities/socialFeed/instagramFeed.ts`**: o teto defensivo de páginas passa a **derivar de `maxResults`** (`max(INSTAGRAM_MAX_MEDIA_PAGES, ceil(maxResults / INSTAGRAM_MAX_RESULTS_CAP))`) — janela de 12 e o lookup/board ficam idênticos (piso 20); só a janela de 10K do período amplia o walk. `loadInstagramFeed` não aprende `since`/`until`.
- **`src/utilities/content/contentPieceProfileImport.ts`**: `listContentPieceProfileImportCandidates({payload, actor, window = {mode:'recent'}, loadFeed?, fetchImpl?, now?})`; recentes = hoje (`maxResults: CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW = 12`, timeout 30 s); período = `maxResults: CONTENT_PIECE_PROFILE_IMPORT_SCAN_LIMIT = 10_000`, `shouldStopAt` por `timestamp < fromMs`, timeout `CONTENT_PIECE_PROFILE_IMPORT_PERIOD_FEED_TIMEOUT_MS = 120_000`; lookup de identidade em chunks de 2.000 URLs via `contentPieceProfileFeedIdentityUrls`; retorno ganha `truncated` (inferido por `contentPieceProfileWindowStartReached`). `createContentPieceFromProfilePost` (`:205`) e o fail-closed (`:110-146`) intocados.
- **`src/lib/schemas/contentPiece.ts`**: `contentPieceProfileImportRequestSchema` vira união com default recente + refine de período via `contentPieceProfilePeriodError`; safe messages existentes bastam.
- **`src/app/(campaign)/campanha/actions/contentPieces.ts`**: `listContentPieceProfileImportCandidatesForActor(input = {})` re-parseia e repassa `window`; gate fresco intocado (`:388-395`).
- **`.../conteudos/importar/route.ts`**: handler passa o body parseado à action; envelope/safe messages inalterados. **`.../importar/criar/route.ts`** intocada.
- **`.../conteudos/types.ts`**: `ContentPieceProfileImportCandidatesResponse` ganha `truncated: boolean` (aditivo).
- **`src/components/campaign/content/ImportContentPieceProfileDialog.tsx`**: seletor de janela (radiogroup cena 01/02: "Recentes · Padrão" default; "Período" com Desde/Até nativos + chips "Últimos 30 dias"/"Últimos 6 meses"/"Este ano", via `subtractBahiaCivilDays`/`formatBahiaCivilDate`), prévia+confirmação (cena 03), progresso real (cena 04, barra com `aria-valuenow`), recibo com a linha da janela + aviso de corte (cenas 05/06d), vazio (06a) e falha com "Tentar de novo" (06b); mantém loop sequencial, bloqueio de fechar em running e `router.refresh()`.
- **`page.tsx`** (lista): intocada — toolbar/banner/empty da C230 já cobrem o fail-closed (cena 06c).
- **`docs/changelog/2026-09-29-c235.md`** (novo).
**Migration: sem migration** — nenhuma collection/global/field muda; `push:false` intacto; sem `generate:types` (tipos novos são de aplicação).
**Access / Consent:** sem Consent (sem PII nova) e sem chave hardcoded; gate fresco `canReadCommunicationCatalog` nas actions (como as irmãs); leitura do global via Local API sem `user` (bypass admin já documentado em `contentPieceProfileImport.ts:119-121`) só para o boolean/credencial, e o listing com `user: actor` + `overrideAccess:false` (`:83-84`). Sem escrita multi-collection nova: a fase A só lê; a fase B é o `payload.create` único já transacional do C230.
**UI:** Impeccable **C** (encaixe no diálogo existente). Shells/componentes reusados: `Dialog/Alert/Badge/Button/Spinner`, `postCampaignJson`, `buildContentPieceListHref` (`contentPieceListUrl.ts:89-90`). **Trigger de design:** DEGRADED → certificação/estouro do designer primário antes do markup (D6); estados não desenhados param a execução.
### Dados → forma (se aplicável)
N/A — operação/curadoria, não analytics: o tamanho da janela e o recibo são contagem operacional (novas/já estavam/falhas) e 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 / schema+server — quota ~50%:** módulo puro (`contentPieceProfileWindow.ts`) + re-export do CLI; teto de páginas derivado de `maxResults` no feed; `contentPieceProfileImport` com janela/truncated; schema/route/action/types. **Tracer bullet cedo:** int com `loadFeed` fake — período devolve `found`/`existingCount`/`candidates` deduplicados por identidade e `truncated` correto com o `shouldStopAt` pinado no limite; recentes segue com `receivedMaxResults === 12` e os pins C230 verdes; CLI spec verde pelo re-export. Só então a UI.
2. **UI — quota ~30%:** port das cenas 01–08 no diálogo, **após certificação do designer** (D6). Prova: build/typecheck e revisão no browser; sem tocar `page.tsx`.
3. **Gates — quota ~20%:** `pnpm gate:fast`; `pnpm test:int`; e2e selecionado/curado (o diff acorda `campaignSpeechAcervo` pelo risk mapping; nenhuma spec e2e nova); crítica final do designer certificada + sign-off humano (DEGRADED) com `Design tier:` no PR; `pnpm push` via GitHub.
### Testes previstos
- **`tests/unit/contentPieceProfileWindow.unit.spec.ts` (novo):** planner com bounds (dentro/fora, `outsideWindow`, duplicata de feed, identidade já catalogada em outra grafia), bounds nulos (modo recentes) e ordenação newest-first; `contentPieceProfilePeriodError` (formato, data impossível 2026-02-30, `until < since`, `since` futuro, válido); `contentPieceProfilePeriodBounds` (início/fim do dia civil BRT, `until` ausente = agora); `contentPieceProfileWindowCovered` (post mais antigo que `fromMs` = coberto; só mais novos = corte; vazio); limiar e label.
- **`tests/unit/instagramFeed.unit.spec.ts` (estender sem quebrar pins):** uma janela mais profunda (`maxResults: 1_100`) anda 22 páginas; default segue 20 (pins de clamp/one-page/walk/early-stop/refresh intactos).
- **`tests/int/contentPiece.int.spec.ts` — novo describe C235 (deps injetadas, sem rede):** período com `loadFeed` fake — `receivedMaxResults === SCAN_LIMIT` e `shouldStopAt(postAntigo) === true`/`shouldStopAt(postNaJanela) === false`; posts fora da janela não entram; dedupe por variante de kind (`/p/` no banco × `/reel/` no feed); `truncated: true` quando o feed não alcança o `since`, `false` quando alcança; período sem credencial → fail-closed sem chamar `loadFeed`; feed lançando → mensagem honesta; envelope da rota com `{}` (recente) e com body de período; período inválido → 400 sem escrita.
- **Pins que seguem intocados:** C230 (`:1653-1929`, janela 12 em `:1696`), C220 (`:1214-1230`), `instagramContentPlan.unit.spec.ts` e `instagramContentImportCli.unit.spec.ts` via re-export.
- **e2e — não entra.** Mesma razão do C230: sem credencial real e o global é estado compartilhado; o int cobre a fronteira. **Gatilho:** regressão do envelope HTTP que o int não pegar → e2e HTTP curto com o stub (`tests/e2e/instagram-stub.mjs`), semeando/restaurando o global em `finally`.
## Rabbit holes / Não escopo (engenharia)
- **Backfill total/"importa as 10K de uma vez"** — a janela é escolhida; o corte é dito e reexecutar continua sem duplicar; varredura automática é C212 §Q4.
- **Cursor persistido/retomada/painel de varredura** — nada de estado próprio; o recibo é a verdade.
- **`since`/`until` nativos da edge** — fora (D1); não ensinar isso ao `loadInstagramFeed`.
- **Segunda identidade/fila/resolvedor; agendador/webhook; auto-publicar; triagem peça a peça; analytics/stories** — permanentes (intenção/C211/C213).
- **Progresso de busca por página, scroll infinito de seleção, edição em massa** — fora.
- **Tocar board/feed da home, snapshot/cache/kill switch/lock do `SocialFeedSettings`, outras rotas da Central** — o diff é zero fora do listado.
- **Mexer no `instagramFeed.ts` além do teto de páginas derivado** — sem `since`/`until`, sem novo retorno.
## Riscos e mitigação
- **Janela antiga = muitas páginas → rate limit/timeout:** o walk respeita `AbortSignal` de 120 s; estourou, mensagem honesta e nada criado (criação só após a prévia); reexecutar converge. Se virar rotina, o item é a varredura com cursor (C212 §Q4).
- **`truncated` por cobertura tem falso positivo quando o perfil inteiro está dentro da janela** (conta nasceu depois do `since`): o aviso é conservador ("importamos o que foi possível neste recorte") e nunca esconde corte; gatilho: se incomodar, o feed devolve o motivo do fim do walk (`stopReason`) e o flag refina.
- **Página do feed com menos de 50 itens:** o teto de páginas pode cortar antes dos 10K, mas a cobertura denuncia `truncated` — honesto.
- **Mover o planner quebra o CLI/pins:** re-export com os nomes antigos; `pnpm gate:fast` (knip/cycles/unit) pega qualquer órfão.
- **Datas impossíveis normalizadas pelo parse** (`2026-02-30` → março): validação por round-trip em `contentPieceProfilePeriodError` no schema e no cliente.
- **Divergência prévia × criação (corrida):** o probe da fase B responde `existing` e o recibo fecha nas categorias; candidato criado por outro ator conta em "já estavam".
- **Design DEGRADED:** gate duro — sem certificação não há markup; sem sign-off humano não há push.
- **`page.tsx`/board/feed:** intocados por construção; `loadInstagramFeed` só ganha o teto derivado.
## Triage do /simplify (2026-09-29)
Dois revisores (estrutural + qualidade) rodaram sobre o diff; os fixes baratos já estão na entrega (preprocess do schema restrito a `{}` — fim do downgrade silencioso de `{since,until}` sem `mode`; teto de páginas derivado de `maxResults` em vez de arg público; `contentPieceProfileWindowStartReached` no lugar de "covered"; tipo do post reusando `ContentPieceProfilePost`; pins de `formatCivilDateLabel`/`subtractBahiaCivilMonths`; spec do CLI reduzido a pin de re-export; `role="alert"` redundante e `setSearching(false)` no-op removidos; radios com roving tabindex/setas; labels de data sem `aria-label` divergente; `Number.isFinite` redundante no early-stop).
- **Já resolvido (não reabrir):** os fixes acima; o `maxMediaPages` público removido e os pins atualizados.
- **Adiado com gatilho:**
- **Fatia do diálogo em steps locais** (`ImportContentPiecePreviewDialog` tem a cadeia de fases em JSX): gatilho — a certificação de design mexer na estrutura do diálogo ou um passo novo entrar; hoje a estrutura é o port fiel do artefato.
- **`loadConfiguredInstagramFeed`** acumula fail-closed + validação/bounds + composição + persistência: a parte pura já tem dono (`contentPieceProfileWindow.ts`); gatilho — 2º consumidor do mapeamento janela→`maxResults`/timeout/early-stop.
- **`truncated` conservador** (S5): gatilho — o feed devolver `stopReason` (ver Riscos).
- **e2e do diálogo** (S6): gatilho — regressão do envelope HTTP que o int não pegar (ver Testes).
- **Explicitamente fora:** `formatCivilDateLabel` privado do `activityAllDay.ts` (recebe instante ISO, semântica distinta — não é duplicata); tipos de wire importados de `app/(campaign)/.../types` em client components (padrão do repo, precedente C230).
- **Registro adiado:** não há Issue nova neste fechamento — o fluxo para antes do PR (design DEGRADED); o gatilho da fatia do diálogo fica aqui e o débito pode virar `C235-FOLLOWUP-DRY` (`depends: 1386`) quando o fechamento landar.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: recentes default preservado; período com desde/até opcional cobre a janela pedida pela via oficial; prévia com tamanho antes de criar em período; recibo honesto (novas/já estavam/falharam + motivo) e aviso de corte quando a via oficial não alcança; dedupe por identidade (sobreposição/reexecução/link manual sem gêmeas); nada publicado; fail-closed de credencial; só perfil próprio/API oficial; token nunca em log.
- [ ] Invariantes AGENTS/engineering-standards: gate fresco `canReadCommunicationCatalog`; `user` + `overrideAccess:false` (bypass admin só o já documentado do global); **sem migration/collection/Consent novo**; sem escrita multi-collection nova; `lib/` não importa `utilities/` e o módulo novo é puro/client-safe; `instagramFeed.ts` não importa `@payload-config`; um dono por mecanismo (planner movido, sem segundo dedupe/fila/identidade); board/feed da home e pipeline C220 intocados.
- [ ] Testes de domínio previstos (unit/int) verdes e pins C230/C220/CLI verdes; nenhuma spec e2e nova; manifest sem edição e seleção não-zero.
- [ ] UI apenas após certificação do designer; crítica final + sign-off humano (DEGRADED) com `Design tier:` no PR antes do push.
- [ ] `pnpm gate:fast` verde; `pnpm test:int` verde; e2e selecionado/curado verde; `pnpm push` via GitHub.
## Self-score
**Self-score decision-quality: 4/5.** (1) Decisões caras com Opções/Recomendação/Rejeitadas explícitas (mecânica da varredura, dono do planner, contrato/datas, fluxo do diálogo, timeout, gate de design, testes); (2) cabe no appetite — move a pura, adiciona um arg opcional ao feed e porta cenas no diálogo existente, sem schema/infra; (3) rabbit holes nomeados (backfill, cursor, `since/until`, segunda fila, agendador, analytics, progresso inventado); (4) depth check: planner e identidade movidos para o dono em `src/lib`, CLI delega, dedupe segue num predicado único e o cliente do feed só parametriza o teto; (5) aceite de produto intacto. Não 5/5 por três incertezas assumidas: (a) o falso positivo conservador de `truncated` na cobertura; (b) o limiar de "Janela grande" (50) inferido do design/mock; (c) o design **DEGRADED**, cuja certificação é pré-condição da fase 2 e não está sob controle da execução.

View file

@ -8,10 +8,15 @@
*/
import {
contentPiecePostIdentityUrls,
contentPieceProfileCandidateFromPost,
parseContentPieceLink,
} from '../../src/lib/contentPiece.ts'
contentPieceProfileFeedIdentityUrls,
planContentPieceProfileWindow,
} from '../../src/lib/contentPieceProfileWindow.ts'
// C235 — the window filter and the identity dedupe moved to their single owner
// in `src/lib` (the server importer reads the same policy); the names below are
// the CLI contract this module has always exported.
export const planInstagramContentWindow = planContentPieceProfileWindow
export const instagramContentFeedIdentityUrls = contentPieceProfileFeedIdentityUrls
/** Default recency window of one import run (the request's 60 days). */
const INSTAGRAM_CONTENT_DEFAULT_DAYS = 60
@ -81,104 +86,6 @@ export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => {
return options
}
/**
* The identity URLs of every parseable post of the feed — what the dedupe
* query asks the Central for in ONE `sourceUrl in` lookup. A permalink the
* catalogue cannot parse contributes nothing (it never becomes a candidate).
*
* @param {Array<{ permalink: string, mediaType: string, mediaUrl?: string | null }>} posts
* @returns {string[]}
*/
export const instagramContentFeedIdentityUrls = (posts) => {
const urls = new Set()
for (const post of posts) {
const candidate = contentPieceProfileCandidateFromPost(post)
if (!candidate) continue
const link = parseContentPieceLink(candidate.url)
if (!link) continue
for (const url of contentPiecePostIdentityUrls(link)) urls.add(url)
}
return [...urls]
}
/**
* Filters the feed to the recency window and splits it into novelties, pieces
* the Central already had and posts that cannot be imported (outside the
* window, unparseable permalink or a repeated identity inside the feed).
* Candidates keep the feed order (newest first).
*
* `found` counts the parseable posts inside the window: candidates + already
* catalogued + repeated identities.
*
* @param {{
* posts: Array<{ permalink: string, mediaType: string, mediaUrl?: string | null, timestamp: string }>,
* existingSourceUrls?: string[],
* from: string,
* to: string,
* }} input
*/
export const planInstagramContentWindow = ({ posts, existingSourceUrls = [], from, to }) => {
const fromMs = Date.parse(from)
const toMs = Date.parse(to)
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) {
throw new Error('janela inválida: from/to precisam ser instantes ISO.')
}
const existing = new Set(existingSourceUrls)
const seen = new Set()
const candidates = []
const existingShortcodes = []
let outsideWindow = 0
let malformed = 0
let feedDuplicates = 0
for (const post of posts) {
const timestamp = Date.parse(post.timestamp)
if (!Number.isFinite(timestamp) || timestamp < fromMs || timestamp > toMs) {
// An item without a usable timestamp cannot be placed in the window.
if (!Number.isFinite(timestamp)) malformed += 1
else outsideWindow += 1
continue
}
const candidate = contentPieceProfileCandidateFromPost(post)
const link = candidate ? parseContentPieceLink(candidate.url) : null
if (!candidate || !link) {
malformed += 1
continue
}
const identityUrls = contentPiecePostIdentityUrls(link)
if (identityUrls.some((url) => existing.has(url))) {
existingShortcodes.push(link.shortcode)
continue
}
if (identityUrls.some((url) => seen.has(url))) {
feedDuplicates += 1
continue
}
identityUrls.forEach((url) => seen.add(url))
candidates.push({
url: candidate.url,
shortcode: link.shortcode,
linkOnlyReason: candidate.linkOnlyReason,
timestamp: post.timestamp,
mediaType: post.mediaType,
})
}
return {
window: { from, to },
found: candidates.length + existingShortcodes.length + feedDuplicates,
existingCount: existingShortcodes.length,
existingShortcodes,
feedDuplicates,
malformed,
outsideWindow,
candidates,
}
}
/**
* The publish rule of the import: only a piece the pipeline finished (`pronto`)
* goes public, and only when the run asked for it. A `falhou` piece stays a

View file

@ -23,8 +23,8 @@ export const POST = campaignJsonMutationRoute(
safeMessages: CONTENT_PIECE_PROFILE_IMPORT_SAFE_MESSAGES,
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
},
async () => {
const listing = await listContentPieceProfileImportCandidatesForActor()
async (body) => {
const listing = await listContentPieceProfileImportCandidatesForActor(body)
return NextResponse.json<ContentPieceProfileImportCandidatesResponse>({
status: 'success',
...listing,

View file

@ -10,13 +10,15 @@ export type ContentPieceLinkResponse =
| { status: 'success'; piece: ContentPieceViewModel }
| { status: 'error'; message: string }
/** Wire contract of `POST .../conteudos/importar` (C230) — profile listing. */
/** Wire contract of `POST .../conteudos/importar` (C230/C235) — profile listing. */
export type ContentPieceProfileImportCandidatesResponse =
| {
status: 'success'
found: number
existingCount: number
candidates: ContentPieceProfileCandidate[]
/** The official API did not reach the requested window start (C235). */
truncated: boolean
}
| { status: 'error'; message: string }

View file

@ -21,6 +21,7 @@ import {
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
contentPieceDeleteRequestSchema,
contentPieceLinkRequestSchema,
contentPieceProfileImportRequestSchema,
contentPiecePublicationRequestSchema,
contentPieceRetryRequestSchema,
contentPieceStatusRequestSchema,
@ -385,14 +386,16 @@ export const addContentPieceByLinkForActor = async (input: {
* 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()
export const listContentPieceProfileImportCandidatesForActor = async (
input: unknown = {},
): Promise<ContentPieceProfileImportListing> => {
const window = contentPieceProfileImportRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
return listContentPieceProfileImportCandidates({ payload, actor })
}
return listContentPieceProfileImportCandidates({ payload, actor, window })
}
/**
* C230 — creates ONE draft from ONE listed media through the C220 pipeline

View file

@ -1,9 +1,17 @@
'use client'
import { CheckIcon, CircleAlertIcon, InstagramIcon } from 'lucide-react'
import {
CalendarDaysIcon,
CalendarXIcon,
CheckIcon,
CircleAlertIcon,
InstagramIcon,
SearchIcon,
TriangleAlertIcon,
} from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useRef, useState, type KeyboardEvent } from 'react'
import type {
ContentPieceProfileImportCandidatesResponse,
@ -21,13 +29,27 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/Progress'
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 {
formatBahiaCivilDate,
formatCivilDateLabel,
subtractBahiaCivilDays,
subtractBahiaCivilMonths,
} from '@/lib/campaignTime'
import type { ContentPieceProfileLinkOnlyReason } from '@/lib/contentPiece'
import {
CONTENT_PIECE_PROFILE_IMPORT_LARGE_WINDOW_THRESHOLD,
contentPieceProfilePeriodError,
contentPieceProfileWindowLabel,
type ContentPieceProfileWindow,
} from '@/lib/contentPieceProfileWindow'
import { cn } from '@/lib/utils'
import { buildContentPieceListHref } from '@/utilities/content/contentPieceListUrl'
@ -35,7 +57,9 @@ const GENERIC_ERROR_MESSAGE = 'Não foi possível concluir a importação. Tente
const PROFILE_HANDLE = '@depjorgesolla'
type ImportPhase = 'intro' | 'running' | 'done'
type ImportPhase = 'window' | 'confirm' | 'running' | 'done' | 'empty' | 'error'
type WindowMode = 'recent' | 'period'
type ImportResult = {
created: number
@ -45,8 +69,14 @@ type ImportResult = {
linkOnly: ContentPieceProfileLinkOnlyReason[]
/** First non-generic failure message of the loop, shown in the receipt. */
failureMessage: string | null
/** The official API did not reach the window start (design scene 06d). */
truncated: boolean
/** "Período · 01/08/2026 → hoje" in period mode; null for the recency slice. */
windowLabel: string | null
}
type ImportListing = Extract<ContentPieceProfileImportCandidatesResponse, { status: 'success' }>
const IMPORT_STATUS: Record<
ContentPieceProfileLinkOnlyReason,
{ title: string; description: string }
@ -72,13 +102,17 @@ const countByReason = (
return [...counts]
}
const plural = (count: number, singular: string, pluralForm: string): string =>
count === 1 ? singular : pluralForm
/**
* 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.
* C230/C235 — "Importar do perfil": the assessoria brings the 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. C235 adds the window
* choice: Recents stays the default single-click gesture; a period is an
* explicit gesture that shows the honest size (new · already there · total)
* before creating. Nothing is published: every novelty lands as Rascunho.
*/
export const ImportContentPieceProfileDialog = ({
configured,
@ -90,55 +124,72 @@ export const ImportContentPieceProfileDialog = ({
}) => {
const router = useRouter()
const [open, setOpen] = useState(false)
const [phase, setPhase] = useState<ImportPhase>('intro')
const [listing, setListing] = useState(false)
const [phase, setPhase] = useState<ImportPhase>('window')
const [mode, setMode] = useState<WindowMode>('recent')
const [since, setSince] = useState('')
const [until, setUntil] = useState('')
const [windowError, setWindowError] = useState<string | null>(null)
const [searching, setSearching] = useState(false)
const [listing, setListing] = useState<ImportListing | null>(null)
const [progress, setProgress] = useState({ done: 0, total: 0 })
const [result, setResult] = useState<ImportResult | null>(null)
const [error, setError] = useState<string | null>(null)
const recentRadioRef = useRef<HTMLButtonElement>(null)
const periodRadioRef = useRef<HTMLButtonElement>(null)
const today = formatBahiaCivilDate(new Date())
const selectWindowMode = (next: WindowMode) => {
setMode(next)
setWindowError(null)
}
// WAI-ARIA radiogroup: arrows move the selection and the focus (roving
// tabindex), so the two window options behave like one control.
const handleWindowChoiceKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (!['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'].includes(event.key)) return
event.preventDefault()
const next = mode === 'recent' ? 'period' : 'recent'
selectWindowMode(next)
;(next === 'recent' ? recentRadioRef : periodRadioRef).current?.focus()
}
const reset = () => {
setPhase('intro')
setListing(false)
setPhase('window')
setMode('recent')
setSince('')
setUntil('')
setWindowError(null)
setSearching(false)
setListing(null)
setProgress({ done: 0, total: 0 })
setResult(null)
setError(null)
}
const runImport = async () => {
const periodWindow = (): ContentPieceProfileWindow => ({
mode: 'period',
since,
until: until || undefined,
})
const requestWindow = (): ContentPieceProfileWindow =>
mode === 'recent' ? { mode: 'recent' } : periodWindow()
const requestLabel = (request: ContentPieceProfileWindow): string | null =>
request.mode === 'period' ? `Período · ${contentPieceProfileWindowLabel(request)}` : null
const runCreationLoop = async (window: ContentPieceProfileWindow, listed: ImportListing) => {
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 })
setProgress({ done: 0, total: listed.candidates.length })
let created = 0
let existing = candidates.existingCount
let existing = listed.existingCount
let failed = 0
let failureMessage: string | null = null
const linkOnly: ContentPieceProfileLinkOnlyReason[] = []
for (const [index, candidate] of candidates.candidates.entries()) {
for (const [index, candidate] of listed.candidates.entries()) {
try {
const { ok, payload } = await postCampaignJson<ContentPieceProfileImportCreateResponse>(
CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_CREATE_HREF,
@ -160,15 +211,311 @@ export const ImportContentPieceProfileDialog = ({
} catch {
failed += 1
}
setProgress({ done: index + 1, total: candidates.candidates.length })
setProgress({ done: index + 1, total: listed.candidates.length })
}
setResult({ created, existing, failed, linkOnly, failureMessage })
setResult({
created,
existing,
failed,
linkOnly,
failureMessage,
truncated: listed.truncated,
windowLabel: requestLabel(window),
})
setPhase('done')
router.refresh()
}
const runSearch = async () => {
const request = requestWindow()
if (request.mode === 'period') {
const message = contentPieceProfilePeriodError({
since: request.since,
until: request.until ?? null,
today,
})
if (message) {
setWindowError(message)
return
}
}
setWindowError(null)
setError(null)
setListing(null)
setPhase('running')
setSearching(true)
setProgress({ done: 0, total: 0 })
let found: ImportListing
try {
const { ok, payload } = await postCampaignJson<ContentPieceProfileImportCandidatesResponse>(
CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_HREF,
request,
)
if (!ok || payload.status !== 'success') {
setError(payload.status === 'error' ? payload.message : GENERIC_ERROR_MESSAGE)
setPhase('error')
return
}
found = payload
} catch {
setError(GENERIC_ERROR_MESSAGE)
setPhase('error')
return
}
setListing(found)
setSearching(false)
if (request.mode === 'recent') {
await runCreationLoop(request, found)
return
}
if (found.found === 0) {
setPhase('empty')
return
}
if (found.candidates.length === 0) {
setResult({
created: 0,
existing: found.existingCount,
failed: 0,
linkOnly: [],
failureMessage: null,
truncated: found.truncated,
windowLabel: requestLabel(request),
})
setPhase('done')
return
}
setPhase('confirm')
}
const linkOnlyRows = result ? countByReason(result.linkOnly) : []
const progressPercent =
progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0
const largeWindow =
listing !== null &&
listing.candidates.length > CONTENT_PIECE_PROFILE_IMPORT_LARGE_WINDOW_THRESHOLD
const windowStep = (
<>
<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>
Escolha quais publicações de <b className="text-foreground">{PROFILE_HANDLE}</b> procurar.
Só as que ainda não estão na Central entram, sempre como rascunho.
</DialogDescription>
</DialogHeader>
<div className="mt-5">
<p className="text-sm font-semibold">Qual janela de publicações?</p>
<p className="mt-0.5 text-xs text-muted-foreground">
O recorte usa a data de publicação do post.
</p>
<div
className="mt-3 space-y-2"
role="radiogroup"
aria-label="Janela de publicações"
onKeyDown={handleWindowChoiceKeyDown}
>
<div className="overflow-hidden rounded-lg border">
<button
ref={recentRadioRef}
type="button"
role="radio"
aria-checked={mode === 'recent'}
tabIndex={mode === 'recent' ? 0 : -1}
onClick={() => selectWindowMode('recent')}
className={cn(
'flex w-full items-start gap-3 p-3.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
mode === 'recent' ? 'bg-stone-50' : 'bg-white hover:bg-stone-50',
)}
>
<span
className={cn(
'mt-0.5 size-4 shrink-0 rounded-full border-2',
mode === 'recent'
? 'border-primary bg-primary ring-2 ring-white ring-inset'
: 'border-stone-300',
)}
aria-hidden="true"
/>
<span className="min-w-0">
<span className="flex flex-wrap items-center gap-2">
<b className="text-sm">Recentes</b>
<Badge variant="secondary">Padrão</Badge>
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
As publicações mais novas do perfil. É o comportamento de sempre.
</span>
</span>
</button>
</div>
<div className="overflow-hidden rounded-lg border">
<button
ref={periodRadioRef}
type="button"
role="radio"
aria-checked={mode === 'period'}
tabIndex={mode === 'period' ? 0 : -1}
onClick={() => selectWindowMode('period')}
className={cn(
'flex w-full items-start gap-3 p-3.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
mode === 'period' ? 'bg-stone-50' : 'bg-white hover:bg-stone-50',
)}
>
<span
className={cn(
'mt-0.5 size-4 shrink-0 rounded-full border-2',
mode === 'period'
? 'border-primary bg-primary ring-2 ring-white ring-inset'
: 'border-stone-300',
)}
aria-hidden="true"
/>
<span className="min-w-0">
<span className="flex items-center gap-2">
<b className="text-sm">Período</b>
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
Escolha desde uma data; a data final é opcional.
</span>
</span>
</button>
{mode === 'period' ? (
<div className="border-t bg-white px-3.5 py-3">
<div className="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Desde</span>
<Input
type="date"
className="mt-1 min-h-11"
value={since}
max={today}
onChange={(event) => {
setSince(event.target.value)
setWindowError(null)
}}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">
Até (opcional)
</span>
<Input
type="date"
className="mt-1 min-h-11"
value={until}
max={today}
placeholder="dd/mm/aaaa"
onChange={(event) => {
setUntil(event.target.value)
setWindowError(null)
}}
/>
</label>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<button
type="button"
className="min-h-8 rounded-full border px-2.5 text-xs font-medium hover:bg-stone-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
setSince(subtractBahiaCivilDays(today, 30))
setUntil('')
setWindowError(null)
}}
>
Últimos 30 dias
</button>
<button
type="button"
className="min-h-8 rounded-full border px-2.5 text-xs font-medium hover:bg-stone-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
setSince(subtractBahiaCivilMonths(today, 6))
setUntil('')
setWindowError(null)
}}
>
Últimos 6 meses
</button>
<button
type="button"
className="min-h-8 rounded-full border px-2.5 text-xs font-medium hover:bg-stone-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-sm:hidden"
onClick={() => {
setSince(`${today.slice(0, 4)}-01-01`)
setUntil('')
setWindowError(null)
}}
>
Este ano
</button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Deixe “Até” em branco para buscar até hoje.
</p>
</div>
) : null}
</div>
</div>
{windowError ? (
<p className="mt-2 text-xs font-medium text-red-700" role="alert">
{windowError}
</p>
) : null}
</div>
<div className="mt-4 space-y-2 rounded-lg border bg-stone-50 p-3.5 text-xs leading-5 text-muted-foreground">
<p className="flex gap-2">
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden="true" />
<span>
<b className="text-foreground">Somente novidades</b> — o que já está é reconhecido, sem
cópias.
</span>
</p>
<p className="flex gap-2">
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden="true" />
<span>
<b className="text-foreground">Tudo entra como Rascunho</b> — nada é publicado
automaticamente.
</span>
</p>
<p className="flex gap-2">
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden="true" />
<span>
<b className="text-foreground">Só o perfil oficial, pela API oficial</b> — sem scraping.
</span>
</p>
</div>
<DialogFooter className="max-sm:flex-col-reverse">
<Button
type="button"
variant="outline"
className="min-h-11 max-sm:w-full"
onClick={() => {
setOpen(false)
reset()
}}
>
Cancelar
</Button>
<Button type="button" className="min-h-11 max-sm:w-full" onClick={() => void runSearch()}>
<SearchIcon data-icon="inline-start" aria-hidden="true" />
Buscar publicações
</Button>
</DialogFooter>
</>
)
return (
<Dialog
@ -209,12 +556,29 @@ export const ImportContentPieceProfileDialog = ({
<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.'
? `Nenhuma novidade na ${
result.windowLabel ? 'janela escolhida' : 'janela recente'
} — a Central já estava em dia.`
: 'As novidades já estão na Central como rascunho. Nada foi publicado.'}
</DialogDescription>
{result.windowLabel ? (
<p className="text-xs text-muted-foreground">{result.windowLabel}</p>
) : null}
</DialogHeader>
<div className="grid grid-cols-3 gap-2">
{result.truncated ? (
<div className="mt-4 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3.5 text-xs leading-5 text-amber-900">
<TriangleAlertIcon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<div>
<b className="text-sm">A janela pedida é maior do que a via oficial alcança.</b> O
Instagram entrega as <b>10K mídias mais recentes</b>; importamos o que foi
possível neste recorte. Reexecutar a mesma janela continua de onde parou, sem
duplicar.
</div>
</div>
) : null}
<div className="mt-4 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>
@ -232,7 +596,7 @@ export const ImportContentPieceProfileDialog = ({
</div>
{linkOnlyRows.length > 0 || result.failed > 0 ? (
<div className="overflow-hidden rounded-lg border text-xs">
<div className="mt-4 overflow-hidden rounded-lg border text-xs">
{linkOnlyRows.map(([reason, count], index) => {
const status = IMPORT_STATUS[reason]
return (
@ -277,11 +641,11 @@ export const ImportContentPieceProfileDialog = ({
</div>
) : null}
<DialogFooter>
<DialogFooter className="max-sm:flex-col-reverse">
<Button
type="button"
variant="outline"
className="min-h-11"
className="min-h-11 max-sm:w-full"
onClick={() => {
setOpen(false)
reset()
@ -289,7 +653,7 @@ export const ImportContentPieceProfileDialog = ({
>
Fechar
</Button>
<Button asChild className="min-h-11">
<Button asChild className="min-h-11 max-sm:w-full">
<Link
href={buildContentPieceListHref({ page: 1, statuses: ['rascunho'] }, 1)}
onClick={() => {
@ -302,6 +666,180 @@ export const ImportContentPieceProfileDialog = ({
</Button>
</DialogFooter>
</>
) : phase === 'confirm' && listing ? (
<>
<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>
A janela escolhida tem <b className="text-foreground">{listing.found}</b>{' '}
{plural(listing.found, 'publicação', 'publicações')}. Confira o tamanho antes de
criar os rascunhos.
</DialogDescription>
</DialogHeader>
<div className="mt-5 space-y-3">
<div className="flex items-center gap-2 rounded-lg border bg-stone-50 px-3.5 py-2.5 text-xs">
<CalendarDaysIcon
className="size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<span className="font-medium">Período</span>
<span className="text-muted-foreground">
{contentPieceProfileWindowLabel(periodWindow())}
</span>
<button
type="button"
className="ml-auto text-xs font-medium text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setPhase('window')}
>
Trocar
</button>
</div>
<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">
{listing.candidates.length}
</b>
<span className="text-xs font-medium text-green-800">novas a criar</span>
</div>
<div className="rounded-lg border bg-stone-50 p-3">
<b className="block text-xl tabular-nums">{listing.existingCount}</b>
<span className="text-xs font-medium text-muted-foreground">já estavam</span>
</div>
<div className="rounded-lg border p-3">
<b className="block text-xl tabular-nums">{listing.found}</b>
<span className="text-xs font-medium text-muted-foreground">total na janela</span>
</div>
</div>
{largeWindow ? (
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3.5 text-xs leading-5 text-amber-900">
<TriangleAlertIcon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<div>
<b className="text-sm">Janela grande.</b> {listing.candidates.length} rascunhos
serão criados de uma vez. Nada é publicado — tudo entra como rascunho para
revisão na lista.
</div>
</div>
) : null}
<p className="rounded-lg bg-muted/70 p-3 text-xs leading-5 text-muted-foreground">
Reexecutar a mesma janela não duplica: o que já está é reconhecido pela identidade
do post. Pode demorar um pouco em janelas grandes.
</p>
</div>
<DialogFooter className="max-sm:flex-col-reverse">
<Button
type="button"
variant="outline"
className="min-h-11 max-sm:w-full"
onClick={() => setPhase('window')}
>
Voltar
</Button>
<Button
type="button"
className="min-h-11 max-sm:w-full"
onClick={() => void runCreationLoop(periodWindow(), listing)}
>
Importar {listing.candidates.length}{' '}
{plural(listing.candidates.length, 'publicação', 'publicações')}
</Button>
</DialogFooter>
</>
) : phase === 'empty' ? (
<>
<DialogHeader>
<div className="mb-4 grid size-10 place-items-center rounded-full border bg-stone-50 text-stone-600">
<CalendarXIcon className="size-5" aria-hidden="true" />
</div>
<DialogTitle className="border-b-0 pb-0">
Nenhuma publicação neste período
</DialogTitle>
<DialogDescription>
Nada de <b className="text-foreground">{PROFILE_HANDLE}</b> foi publicado entre{' '}
<b className="text-foreground">{formatCivilDateLabel(since)}</b> e{' '}
<b className="text-foreground">{until ? formatCivilDateLabel(until) : 'hoje'}</b>.
</DialogDescription>
</DialogHeader>
<p className="mt-4 rounded-lg border bg-stone-50 p-3.5 text-xs leading-5 text-muted-foreground">
O recorte está vazio. Confira as datas ou importe as publicações recentes do perfil.
</p>
<DialogFooter className="max-sm:flex-col-reverse">
<Button
type="button"
variant="outline"
className="min-h-11 max-sm:w-full"
onClick={() => {
setOpen(false)
reset()
}}
>
Fechar
</Button>
<Button
type="button"
className="min-h-11 max-sm:w-full"
onClick={() => setPhase('window')}
>
Escolher outra janela
</Button>
</DialogFooter>
</>
) : phase === 'error' ? (
<>
<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>
{mode === 'period' ? (
<>
A janela de{' '}
<b className="text-foreground">
{contentPieceProfileWindowLabel(periodWindow())}
</b>{' '}
não pôde ser buscada agora.
</>
) : (
<>As publicações recentes não puderam ser buscadas agora.</>
)}
</DialogDescription>
</DialogHeader>
<Alert variant="destructive" className="mt-4 py-2">
<CircleAlertIcon aria-hidden="true" />
<AlertDescription className="text-xs">
<b>Não foi possível buscar as publicações.</b>{' '}
{error ?? 'A API oficial não respondeu.'} Nada foi criado.
</AlertDescription>
</Alert>
<DialogFooter className="max-sm:flex-col-reverse">
<Button
type="button"
variant="outline"
className="min-h-11 max-sm:w-full"
onClick={() => {
setOpen(false)
reset()
}}
>
Cancelar
</Button>
<Button
type="button"
className="min-h-11 max-sm:w-full"
onClick={() => void runSearch()}
>
Tentar de novo
</Button>
</DialogFooter>
</>
) : phase === 'running' ? (
<>
<DialogHeader>
@ -319,10 +857,10 @@ export const ImportContentPieceProfileDialog = ({
<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',
searching ? 'bg-stone-100 text-stone-500' : 'bg-green-100 text-green-700',
)}
>
{listing ? (
{searching ? (
<Spinner className="size-3.5" />
) : (
<CheckIcon className="size-3.5" aria-hidden="true" />
@ -330,29 +868,42 @@ export const ImportContentPieceProfileDialog = ({
</div>
<div>
<b className="text-sm">
{listing
? 'Buscando as publicações recentes…'
: 'Publicações recentes encontradas'}
{searching
? mode === 'period'
? 'Buscando as publicações do período…'
: 'Buscando as publicações recentes…'
: mode === 'period' && listing
? `Janela encontrada · ${listing.found} avaliadas`
: 'Publicações recentes encontradas'}
</b>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{listing
{searching
? `Consultando ${PROFILE_HANDLE} pela API oficial.`
: `A busca foi feita em ${PROFILE_HANDLE} pela API oficial.`}
: mode === 'period' && listing
? `${listing.candidates.length} novas · ${listing.existingCount} já estavam. A busca foi feita em ${PROFILE_HANDLE} pela API oficial.`
: `A busca foi feita em ${PROFILE_HANDLE} pela API oficial.`}
</p>
</div>
</div>
{!listing && progress.total > 0 ? (
{!searching && 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>
<div className="min-w-0 flex-1">
<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">
{mode === 'period' ? (
<Progress
className="mt-2 h-2"
value={progressPercent}
aria-label="Progresso da importação"
/>
) : null}
<p className="mt-2 text-xs leading-5 text-muted-foreground">
Criando apenas as novidades como rascunho e reconhecendo o que já estava na
Central.
</p>
@ -363,8 +914,18 @@ export const ImportContentPieceProfileDialog = ({
</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.
{mode === 'period' ? (
<>
<b className="text-foreground">Pode demorar</b> — a janela é grande e cada mídia é
preparada uma a uma. O resultado final separa novas, já existentes e falhas;
nenhuma falha fica escondida.
</>
) : (
<>
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>
@ -375,72 +936,7 @@ export const ImportContentPieceProfileDialog = ({
</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>
</>
windowStep
)}
</DialogContent>
</Dialog>

View file

@ -181,6 +181,19 @@ export const subtractBahiaCivilDays = (civilDate: string, days: number): string
return `${anchor.getUTCFullYear()}-${pad(anchor.getUTCMonth() + 1)}-${pad(anchor.getUTCDate())}`
}
/**
* Subtract whole civil months from a Bahia civil date anchor (`aaaa-mm-dd`),
* clamping the day to the target month (31/08 − 6 = 28/02) — the C235
* "last N months" shortcuts never invent a date that does not exist.
*/
export const subtractBahiaCivilMonths = (civilDate: string, months: number): string => {
const [year, month, day] = civilDate.split('-').map(Number)
const target = new Date(Date.UTC(year, month - 1 - months, 1))
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0))
target.setUTCDate(Math.min(day, lastDay.getUTCDate()))
return `${target.getUTCFullYear()}-${pad(target.getUTCMonth() + 1)}-${pad(target.getUTCDate())}`
}
/**
* Latest of two ISO timestamps, ignoring nulls. String comparison is only
* sound because every writer here produces fixed-width UTC — Payload's own
@ -230,6 +243,16 @@ export const formatBahiaCivilDateTimeLabel = (civil: string): string => {
return `${day}/${month}/${year} às ${hour}:${minute}`
}
/**
* pt-BR label of a Bahia civil date anchor (`aaaa-mm-dd` → `dd/mm/aaaa`) —
* the C235 window inputs speak date-only, same string arithmetic as the
* datetime label below.
*/
export const formatCivilDateLabel = (civil: string): string => {
const [year = '', month = '', day = ''] = civil.split('-')
return `${day}/${month}/${year}`
}
/**
* Date-only pt-BR label of a civil datetime input value (`dd/mm/aaaa`) —
* the C104 all-day mode of the date+time picker, same string arithmetic.

View file

@ -0,0 +1,235 @@
import {
formatBahiaCivilDate,
formatCivilDateLabel,
parseBahiaDateTimeInput,
} from '@/lib/campaignTime'
import {
contentPiecePostIdentityUrls,
contentPieceProfileCandidateFromPost,
parseContentPieceLink,
type ContentPieceProfileLinkOnlyReason,
type ContentPieceProfilePost,
} from '@/lib/contentPiece'
/**
* C235 — the publication window of the profile import: the pure owner of the
* recency filter (each post timestamp against the civil Bahia dates the
* operator typed), the identity dedupe (the feed against the Central in any
* URL spelling and against itself) and the honest coverage check that tells
* when the official API did not reach the requested start. No I/O and no
* `server-only`: the server importer and the ops CLI both read this, and the
* unit specs pin the policy without Payload.
*/
/** The window the operator chose: the default recency slice or a civil period. */
export type ContentPieceProfileWindow =
| { mode: 'recent' }
| { mode: 'period'; since: string; until?: string | null }
/** The feed post the window filter needs: the shared metadata + its timestamp. */
export type ContentPieceProfileWindowPost = ContentPieceProfilePost & { timestamp: string }
export type ContentPieceProfileWindowCandidate = {
url: string
shortcode: string
linkOnlyReason: ContentPieceProfileLinkOnlyReason | null
timestamp: string
mediaType: string
}
export type ContentPieceProfileWindowPlan = {
/** The bounds actually applied; null means unbounded on that side. */
window: { from: string | null; to: string | null }
/** Parseable posts inside the window: candidates + already catalogued + feed twins. */
found: number
existingCount: number
existingShortcodes: string[]
feedDuplicates: number
malformed: number
outsideWindow: number
/** The novelties, in feed order (newest first). */
candidates: ContentPieceProfileWindowCandidate[]
}
/**
* Above this many novelties the confirmation warns that a large batch of
* drafts will be created at once (design scene 03). One feed page is the unit
* the operator already sees in the recency import.
*/
export const CONTENT_PIECE_PROFILE_IMPORT_LARGE_WINDOW_THRESHOLD = 50
type CivilDateCheck = { since: string; until?: string | null }
/** Exact civil-round-trip: a syntactically valid but impossible date fails. */
const isRealCivilDate = (value: string): boolean => {
const instantIso = parseBahiaDateTimeInput(`${value}T00:00`)
return instantIso !== null && formatBahiaCivilDate(new Date(instantIso)) === value
}
/**
* The domain message of an invalid period window, or null when it is valid.
* `today` is a civil Bahia date so the future check is stable and testable.
*/
export const contentPieceProfilePeriodError = ({
since,
until,
today,
}: CivilDateCheck & { today: string }): string | null => {
if (!isRealCivilDate(since)) return 'Informe uma data inicial válida.'
if (until && !isRealCivilDate(until)) return 'Informe uma data final válida.'
if (until && until < since) return 'A data final não pode ser anterior à data inicial.'
if (since > today) return 'A data inicial não pode estar no futuro.'
return null
}
/**
* The instants of the chosen period: the start of the `since` civil day and
* the exclusive end of the `until` civil day (Bahia is UTC-3 all year round,
* so the next civil midnight is a fixed 24h later). An absent `until` is
* open-ended — the feed runs to today.
*/
export const contentPieceProfilePeriodBounds = ({
since,
until,
}: CivilDateCheck): { fromIso: string; toIso: string | null } => {
const fromIso = isRealCivilDate(since) ? parseBahiaDateTimeInput(`${since}T00:00`) : null
if (!fromIso) throw new Error('Informe uma data inicial válida.')
if (!until) return { fromIso, toIso: null }
const untilStart = isRealCivilDate(until) ? parseBahiaDateTimeInput(`${until}T00:00`) : null
if (!untilStart) throw new Error('Informe uma data final válida.')
return { fromIso, toIso: new Date(Date.parse(untilStart) + 86_400_000).toISOString() }
}
/**
* True when the walk went past the start of the window (the feed is
* newest-first, so a post older than `fromIso` proves the window start was
* reached). A null bound means unbounded — always reached. The check is
* conservative on purpose: a profile whose whole history sits inside the
* window is reported as not reached, which only makes the receipt more
* cautious, never less honest.
*/
export const contentPieceProfileWindowStartReached = ({
posts,
fromIso,
}: {
posts: readonly ContentPieceProfileWindowPost[]
fromIso: string | null
}): boolean => {
if (fromIso === null) return true
const fromMs = Date.parse(fromIso)
return posts.some((post) => Date.parse(post.timestamp) < fromMs)
}
/** The pt-BR label of the chosen window ("01/08/2026 → hoje"). */
export const contentPieceProfileWindowLabel = (window: ContentPieceProfileWindow): string => {
if (window.mode === 'recent') return 'Recentes'
const from = formatCivilDateLabel(window.since)
return `${from} → ${window.until ? formatCivilDateLabel(window.until) : 'hoje'}`
}
/**
* The identity URLs of every parseable post of the feed — what the dedupe
* query asks the Central for in `sourceUrl in` lookups. A permalink the
* catalogue cannot parse contributes nothing (it never becomes a candidate).
*/
export const contentPieceProfileFeedIdentityUrls = (
posts: readonly ContentPieceProfileWindowPost[],
): string[] => {
const urls = new Set<string>()
for (const post of posts) {
const candidate = contentPieceProfileCandidateFromPost(post)
if (!candidate) continue
const link = parseContentPieceLink(candidate.url)
if (!link) continue
for (const url of contentPiecePostIdentityUrls(link)) urls.add(url)
}
return [...urls]
}
/**
* Filters the feed to the chosen window and splits it into novelties, pieces
* the Central already had and posts that cannot be imported (outside the
* window, unparseable permalink or a repeated identity inside the feed).
* Candidates keep the feed order (newest first). The bounds are optional:
* without them the whole feed is the window (the recency import).
*
* `found` counts the parseable posts inside the window: candidates + already
* catalogued + repeated identities.
*/
export const planContentPieceProfileWindow = ({
posts,
existingSourceUrls = [],
from = null,
to = null,
}: {
posts: readonly ContentPieceProfileWindowPost[]
existingSourceUrls?: readonly string[]
from?: string | null
to?: string | null
}): ContentPieceProfileWindowPlan => {
const fromMs = from === null ? null : Date.parse(from)
const toMs = to === null ? null : Date.parse(to)
if ((fromMs !== null && !Number.isFinite(fromMs)) || (toMs !== null && !Number.isFinite(toMs))) {
throw new Error('janela inválida: from/to precisam ser instantes ISO ou null.')
}
const existing = new Set(existingSourceUrls)
const seen = new Set<string>()
const candidates: ContentPieceProfileWindowCandidate[] = []
const existingShortcodes: string[] = []
let outsideWindow = 0
let malformed = 0
let feedDuplicates = 0
for (const post of posts) {
const timestamp = Date.parse(post.timestamp)
if (!Number.isFinite(timestamp)) {
// An item without a usable timestamp cannot be placed in any window.
malformed += 1
continue
}
if ((fromMs !== null && timestamp < fromMs) || (toMs !== null && timestamp > toMs)) {
outsideWindow += 1
continue
}
const candidate = contentPieceProfileCandidateFromPost(post)
const link = candidate ? parseContentPieceLink(candidate.url) : null
if (!candidate || !link) {
malformed += 1
continue
}
const identityUrls = contentPiecePostIdentityUrls(link)
const shortcode = link.origin === 'instagram' ? link.shortcode : link.videoId
if (identityUrls.some((url) => existing.has(url))) {
existingShortcodes.push(shortcode)
continue
}
if (identityUrls.some((url) => seen.has(url))) {
feedDuplicates += 1
continue
}
identityUrls.forEach((url) => seen.add(url))
candidates.push({
url: candidate.url,
shortcode,
linkOnlyReason: candidate.linkOnlyReason,
timestamp: post.timestamp,
mediaType: post.mediaType,
})
}
return {
window: { from, to },
found: candidates.length + existingShortcodes.length + feedDuplicates,
existingCount: existingShortcodes.length,
existingShortcodes,
feedDuplicates,
malformed,
outsideWindow,
candidates,
}
}

View file

@ -1,5 +1,6 @@
import { z } from 'zod'
import { formatBahiaCivilDate } from '@/lib/campaignTime'
import {
CONTENT_PIECE_DESCRIPTION_MAX_LENGTH,
CONTENT_PIECE_INSTITUTION_MAX_LENGTH,
@ -10,6 +11,7 @@ import {
CONTENT_PIECE_TITLE_MAX_LENGTH,
CONTENT_PIECE_TYPES,
} from '@/lib/contentPiece'
import { contentPieceProfilePeriodError } from '@/lib/contentPieceProfileWindow'
import { trimmedNullableText } from '@/lib/schemas/primitives'
import { TRANSCRIPT_TEXT_MAX_LENGTH } from '@/lib/speechSearch'
@ -82,11 +84,36 @@ export const contentPieceLinkRequestSchema = z.object({
})
/**
* 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.
* C235 — the window of the profile import: the default recency slice (`{}` or
* `recent`, the C230 contract) or an explicit civil period (`since` required,
* `until` optional). The period is validated against the Bahia civil day —
* real date, order, not in the future — so the wire never carries an
* impossible window.
*/
export const contentPieceProfileImportRequestSchema = z.object({})
export const contentPieceProfileImportWindowSchema = z.discriminatedUnion('mode', [
z.object({ mode: z.literal('recent') }),
z.object({
mode: z.literal('period'),
since: z.string().trim(),
until: z.string().trim().nullish(),
}),
])
export const contentPieceProfileImportRequestSchema = z.preprocess(
(value) =>
value && typeof value === 'object' && Object.keys(value).length === 0
? { mode: 'recent' }
: value,
contentPieceProfileImportWindowSchema.superRefine((window, context) => {
if (window.mode !== 'period') return
const message = contentPieceProfilePeriodError({
since: window.since,
until: window.until ?? null,
today: formatBahiaCivilDate(new Date()),
})
if (message) context.addIssue({ code: 'custom', message })
}),
)
/** The editable catalogue of one piece (the ficha form). */
export const contentPieceUpdateRequestSchema = z.object({

View file

@ -2,14 +2,20 @@ import 'server-only'
import type { Payload } from 'payload'
import { formatBahiaCivilDate } from '@/lib/campaignTime'
import {
contentPieceLinkTitle,
contentPiecePostIdentityUrls,
contentPieceProfileCandidateFromPost,
parseContentPieceLink,
type ContentPieceLink,
type ContentPieceProfileCandidate,
} from '@/lib/contentPiece'
import {
contentPieceProfileFeedIdentityUrls,
contentPieceProfilePeriodBounds,
contentPieceProfilePeriodError,
contentPieceProfileWindowStartReached,
planContentPieceProfileWindow,
type ContentPieceProfileWindow,
} from '@/lib/contentPieceProfileWindow'
import {
CONTENT_PIECE_LINK_INVALID_MESSAGE,
CONTENT_PIECE_PROFILE_IMPORT_FEED_ERROR_MESSAGE,
@ -39,9 +45,26 @@ import {
/** The recency window of one import (12 media ≈ the profile's last days). */
export const CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW = 12
/**
* C235 — the scan ceiling of one period import: the official edge itself only
* answers the 10K most recently created media, so this is the real limit of
* the walk, not a product cap.
*/
export const CONTENT_PIECE_PROFILE_IMPORT_SCAN_LIMIT = 10_000
/** A hung Graph API must not hold the confirm dialog until the job reaper. */
const CONTENT_PIECE_PROFILE_IMPORT_FEED_TIMEOUT_MS = 30_000
/** A period walk can cost many pages; the honest ceiling is still the edge. */
const CONTENT_PIECE_PROFILE_IMPORT_PERIOD_FEED_TIMEOUT_MS = 120_000
/**
* The catalogue identity lookup asks `sourceUrl in (...)`; a 10K-media window
* spells up to 30K URLs, so the probe runs in bounded chunks instead of one
* oversized statement.
*/
const CONTENT_PIECE_PROFILE_IMPORT_IDENTITY_LOOKUP_CHUNK = 2_000
type FeedLoader = typeof loadInstagramFeed
type FetchLike = (input: string, requestInit?: RequestInit) => Promise<Response>
@ -52,6 +75,12 @@ export type ContentPieceProfileImportListing = {
existingCount: number
/** The novelties, in feed order (newest first). */
candidates: ContentPieceProfileCandidate[]
/**
* True when the official API did not reach the start of the requested window
* (edge limit, page ceiling or end of pagination): the receipt says the
* window was not fully covered. Always false for the recency window.
*/
truncated: boolean
}
export type ContentPieceProfileImportOutcome = 'created' | 'existing'
@ -71,24 +100,31 @@ const cataloguedSourceUrls = async ({
actor: CampaignUser
identityUrls: readonly string[]
}): Promise<Set<string>> => {
if (identityUrls.length === 0) return new Set()
const unique = [...new Set(identityUrls)]
const catalogued = new Set<string>()
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,
})
for (
let index = 0;
index < unique.length;
index += CONTENT_PIECE_PROFILE_IMPORT_IDENTITY_LOOKUP_CHUNK
) {
const chunk = unique.slice(index, index + CONTENT_PIECE_PROFILE_IMPORT_IDENTITY_LOOKUP_CHUNK)
const found = await payload.find({
collection: 'contentPiece',
where: { sourceUrl: { in: chunk } },
depth: 0,
limit: 0,
pagination: false,
select: { sourceUrl: true },
user: actor,
overrideAccess: false,
})
for (const doc of found.docs) {
if (typeof doc.sourceUrl === 'string' && doc.sourceUrl) catalogued.add(doc.sourceUrl)
}
}
return new Set(
found.docs
.map((doc) => doc.sourceUrl)
.filter((sourceUrl): sourceUrl is string => Boolean(sourceUrl)),
)
return catalogued
}
/** True when the official credential is armed — what arms the import button. */
@ -106,16 +142,28 @@ export const readContentPieceProfileImportAvailability = async (
* 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.
*
* C235 — the recency window keeps the C230 one-page read; a period window
* scans cursor pages with a date early-stop (the feed is newest-first, so the
* first post older than the requested start ends the walk) up to the edge's
* own 10K-media limit, and denounces the bounds actually applied.
*/
const loadConfiguredInstagramFeed = async ({
payload,
loadFeed,
fetchImpl,
window,
now,
}: {
payload: Payload
loadFeed: FeedLoader
fetchImpl: FetchLike
}): Promise<Awaited<ReturnType<FeedLoader>>> => {
window: ContentPieceProfileWindow
now: Date
}): Promise<{
feed: Awaited<ReturnType<FeedLoader>>
bounds: { fromIso: string; toIso: string | null } | null
}> => {
// 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.
@ -124,14 +172,35 @@ const loadConfiguredInstagramFeed = async ({
throw new Error(CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE)
}
if (window.mode === 'period') {
const error = contentPieceProfilePeriodError({
since: window.since,
until: window.until ?? null,
today: formatBahiaCivilDate(now),
})
if (error) throw new Error(error)
}
const bounds =
window.mode === 'period'
? contentPieceProfilePeriodBounds({ since: window.since, until: window.until ?? null })
: null
const fromMs = bounds ? Date.parse(bounds.fromIso) : null
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,
maxResults: bounds
? CONTENT_PIECE_PROFILE_IMPORT_SCAN_LIMIT
: CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW,
...(fromMs !== null ? { shouldStopAt: (post) => Date.parse(post.timestamp) < fromMs } : {}),
fetchImpl,
signal: AbortSignal.timeout(CONTENT_PIECE_PROFILE_IMPORT_FEED_TIMEOUT_MS),
signal: AbortSignal.timeout(
bounds
? CONTENT_PIECE_PROFILE_IMPORT_PERIOD_FEED_TIMEOUT_MS
: CONTENT_PIECE_PROFILE_IMPORT_FEED_TIMEOUT_MS,
),
})
} catch {
// Token expired, API down: the operator reads the retry copy, never a
@ -142,52 +211,64 @@ const loadConfiguredInstagramFeed = async ({
if (feed.refreshedAccessToken) {
await persistInstagramAccessToken(payload, feed.refreshedAccessToken).catch(() => undefined)
}
return feed
return { feed, bounds }
}
/**
* 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.
* is what the API answered with inside the window (parseable), `existingCount`
* what the Central already had, `candidates` what the confirmation will
* create, newest first, and `truncated` when the official API did not reach
* the requested start.
*
* The recency mode is the C230 behavior untouched (12 media, one page); the
* period mode is the C235 window — the planner is the same owner for both.
*/
export const listContentPieceProfileImportCandidates = async ({
payload,
actor,
window = { mode: 'recent' },
loadFeed = loadInstagramFeed,
fetchImpl = fetch,
now = new Date(),
}: {
payload: Payload
actor: CampaignUser
window?: ContentPieceProfileWindow
loadFeed?: FeedLoader
fetchImpl?: FetchLike
now?: Date
}): 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 { feed, bounds } = await loadConfiguredInstagramFeed({
payload,
loadFeed,
fetchImpl,
window,
now,
})
const catalogued = await cataloguedSourceUrls({
payload,
actor,
identityUrls: parsed.flatMap(({ link }) => contentPiecePostIdentityUrls(link)),
identityUrls: contentPieceProfileFeedIdentityUrls(feed.posts),
})
const plan = planContentPieceProfileWindow({
posts: feed.posts,
existingSourceUrls: [...catalogued],
from: bounds?.fromIso ?? null,
to: bounds?.toIso ?? null,
})
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,
found: plan.found,
existingCount: plan.existingCount,
candidates: plan.candidates.map(({ url, linkOnlyReason }) => ({ url, linkOnlyReason })),
truncated:
feed.posts.length > 0 &&
!contentPieceProfileWindowStartReached({
posts: feed.posts,
fromIso: bounds?.fromIso ?? null,
}),
}
}

View file

@ -148,10 +148,12 @@ export const pickInstagramThumbnail = (item: {
}
/**
* Hard ceiling of pages one feed load may walk (defensive): the Graph API
* returns `limit` items per page, so a 500-media window needs 10 — 20 covers
* it with room, and a pathological API that always answers a cursor cannot
* loop forever.
* Default hard ceiling of pages one feed load may walk (defensive): the Graph
* API returns `limit` items per page, so a 500-media window needs 10 — 20
* covers it with room, and a pathological API that always answers a cursor
* cannot loop forever. A caller asking for a deeper window (the C235 10K
* period import) raises the ceiling implicitly through `maxResults`; the
* default floor keeps the small windows exactly as before.
*/
const INSTAGRAM_MAX_MEDIA_PAGES = 20
@ -270,8 +272,14 @@ export const loadInstagramFeed = async ({
const pageSize = Math.min(Math.max(maxResults, 1), INSTAGRAM_MAX_RESULTS_CAP)
const posts: InstagramPost[] = []
// The defensive ceiling follows the requested window (never below the
// C220/board default) so a 10K-media period walk is not cut at 1000.
const pageCeiling = Math.max(
INSTAGRAM_MAX_MEDIA_PAGES,
Math.ceil(Math.max(maxResults, 1) / INSTAGRAM_MAX_RESULTS_CAP),
)
let after: string | undefined
for (let page = 0; page < INSTAGRAM_MAX_MEDIA_PAGES; page += 1) {
for (let page = 0; page < pageCeiling; page += 1) {
const mediaParams = new URLSearchParams({
fields: MEDIA_FIELDS,
limit: String(pageSize),

View file

@ -61,6 +61,7 @@ import {
} from '@/lib/contentPieceFrame'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
CONTENT_PIECE_LINK_INVALID_MESSAGE,
CONTENT_PIECE_NOT_FOUND_MESSAGE,
@ -90,6 +91,7 @@ import {
import { loadContentPieceListPageData } from '@/utilities/content/contentPiecePageData'
import {
CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW,
CONTENT_PIECE_PROFILE_IMPORT_SCAN_LIMIT,
createContentPieceFromProfilePost,
listContentPieceProfileImportCandidates,
} from '@/utilities/content/contentPieceProfileImport'
@ -106,7 +108,7 @@ import {
} from '@/utilities/content/contentPieceUpload'
import { getCollectionListingTag } from '@/utilities/documents'
import { runFfmpeg } from '@/utilities/media/ffmpeg'
import type { InstagramPost } from '@/utilities/socialFeed/instagramFeed'
import type { InstagramPost, LoadInstagramFeedArgs } from '@/utilities/socialFeed/instagramFeed'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
@ -1928,6 +1930,168 @@ describe('content pieces (C211)', () => {
})
})
describe('C235 — import a chosen window', () => {
it('scans the period with the date early-stop, the edge ceiling and the window filter', async () => {
await setInstagramSettings(true)
const marker = Date.now().toString(36)
await createPiece({
title: 'Peça já catalogada na janela',
origin: 'instagram',
withMedia: false,
sourceUrl: `https://www.instagram.com/p/JA${marker}/`,
})
const received: LoadInstagramFeedArgs[] = []
const listing = await listContentPieceProfileImportCandidates({
payload,
actor: communicator,
window: { mode: 'period', since: '2026-08-01', until: '2026-09-20' },
now: new Date('2026-09-29T12:00:00.000Z'),
loadFeed: async (feedArgs) => {
received.push(feedArgs)
return {
username: 'depjorgesolla',
posts: [
instagramPost({
id: 'dentro',
permalink: `https://www.instagram.com/reel/DE${marker}/`,
timestamp: '2026-09-15T12:00:00.000Z',
}),
// The same post the Central already has, served under another kind.
instagramPost({
id: 'ja',
permalink: `https://www.instagram.com/reel/JA${marker}/`,
timestamp: '2026-09-10T12:00:00.000Z',
}),
instagramPost({
id: 'depois',
permalink: `https://www.instagram.com/reel/DP${marker}/`,
timestamp: '2026-09-25T12:00:00.000Z',
}),
// The early-stop proof: the page walked past the requested start.
instagramPost({
id: 'antes',
permalink: `https://www.instagram.com/reel/AN${marker}/`,
timestamp: '2026-07-20T12:00:00.000Z',
}),
],
}
},
})
expect(received).toHaveLength(1)
expect(received[0]!.maxResults).toBe(CONTENT_PIECE_PROFILE_IMPORT_SCAN_LIMIT)
const shouldStopAt = received[0]!.shouldStopAt!
expect(shouldStopAt(instagramPost({ timestamp: '2026-07-31T23:59:59.000Z' }))).toBe(true)
expect(shouldStopAt(instagramPost({ timestamp: '2026-08-01T03:00:00.000Z' }))).toBe(false)
expect(listing.found).toBe(2)
expect(listing.existingCount).toBe(1)
expect(listing.candidates).toEqual([
{ url: `https://www.instagram.com/reel/DE${marker}/`, linkOnlyReason: null },
])
expect(listing.truncated).toBe(false)
})
it('denounces the window when the official API did not reach its start', async () => {
await setInstagramSettings(true)
const marker = Date.now().toString(36)
const listing = await listContentPieceProfileImportCandidates({
payload,
actor: communicator,
window: { mode: 'period', since: '2026-08-01' },
now: new Date('2026-09-29T12:00:00.000Z'),
loadFeed: async () => ({
username: 'depjorgesolla',
posts: [
instagramPost({
permalink: `https://www.instagram.com/reel/DS${marker}/`,
timestamp: '2026-09-15T12:00:00.000Z',
}),
],
}),
})
expect(listing.found).toBe(1)
expect(listing.truncated).toBe(true)
})
it('keeps the recency read as the default: 12 media, no scan bounds, never truncated', async () => {
await setInstagramSettings(true)
const received: LoadInstagramFeedArgs[] = []
const listing = await listContentPieceProfileImportCandidates({
payload,
actor: communicator,
loadFeed: async (feedArgs) => {
received.push(feedArgs)
return { username: null, posts: [] }
},
})
expect(received[0]!.maxResults).toBe(CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW)
expect(received[0]!.shouldStopAt).toBeUndefined()
expect(listing).toEqual({ found: 0, existingCount: 0, candidates: [], truncated: false })
})
it('refuses an impossible period before touching the feed', async () => {
await setInstagramSettings(true)
let called = false
await expect(
listContentPieceProfileImportCandidates({
payload,
actor: communicator,
window: { mode: 'period', since: '2026-02-30' },
now: new Date('2026-09-29T12:00:00.000Z'),
loadFeed: async () => {
called = true
return { username: null, posts: [] }
},
}),
).rejects.toThrow('Informe uma data inicial válida.')
expect(called).toBe(false)
})
it('rejects an invalid period in the listing route without touching the feed', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
await setInstagramSettings(true)
const response = await postProfileImport(
new Request('http://localhost/campanha/comunicacao/conteudos/importar', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: 'http://localhost' },
body: JSON.stringify({
mode: 'period',
since: '2026-02-30',
until: '2026-02-01',
}),
}),
)
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({
status: 'error',
message: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
})
// A body that forgot the mode is never silently downgraded to recents.
const missingMode = await postProfileImport(
new Request('http://localhost/campanha/comunicacao/conteudos/importar', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: 'http://localhost' },
body: JSON.stringify({ since: '2026-08-01', until: '2026-08-31' }),
}),
)
expect(missingMode.status).toBe(400)
await expect(missingMode.json()).resolves.toEqual({
status: 'error',
message: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
})
})
})
it('filters and searches the list', async () => {
const municipality = await payload.find({
collection: 'municipality',

View file

@ -8,9 +8,11 @@ import {
formatBahiaCivilDate,
formatBahiaCivilDateTimeLabel,
formatBahiaEventDateLabel,
formatCivilDateLabel,
hourOptions,
minuteOptionsForStep,
subtractBahiaCivilDays,
subtractBahiaCivilMonths,
timeStepMinutes,
} from '@/lib/campaignTime'
@ -23,6 +25,17 @@ describe('Bahia civil date helpers (B57)', () => {
expect(subtractBahiaCivilDays('2026-08-08', 7)).toBe('2026-08-01')
})
it('subtracts whole civil months, clamping the day to the target month', () => {
expect(subtractBahiaCivilMonths('2026-08-31', 6)).toBe('2026-02-28')
expect(subtractBahiaCivilMonths('2024-08-31', 6)).toBe('2024-02-29')
expect(subtractBahiaCivilMonths('2026-03-15', 6)).toBe('2025-09-15')
expect(subtractBahiaCivilMonths('2026-01-15', 1)).toBe('2025-12-15')
})
it('formats a Bahia civil date anchor as a pt-BR day label', () => {
expect(formatCivilDateLabel('2026-08-01')).toBe('01/08/2026')
})
it('counts calendar days between civil dates', () => {
expect(civilDateDaysBetween('2026-08-01', '2026-08-08')).toBe(7)
})

View file

@ -0,0 +1,223 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
contentPieceProfileFeedIdentityUrls,
contentPieceProfilePeriodBounds,
contentPieceProfilePeriodError,
contentPieceProfileWindowLabel,
contentPieceProfileWindowStartReached,
planContentPieceProfileWindow,
} from '@/lib/contentPieceProfileWindow'
// C235 — the window owner: the civil-date bounds of the operator's period, the
// recency filter over the feed timestamps, the identity dedupe (Central and
// feed itself) and the honest coverage check. Pure: no Payload, no network.
const post = (
shortcode: string,
{
mediaType = 'REEL',
mediaUrl = `https://cdn.example/${shortcode}.mp4`,
timestamp = '2026-09-20T12:00:00.000Z',
permalink,
}: {
mediaType?: string
mediaUrl?: string | null
timestamp?: string
permalink?: string
} = {},
) => ({
id: shortcode,
permalink: permalink ?? `https://www.instagram.com/reel/${shortcode}/`,
mediaType,
mediaUrl,
timestamp,
})
describe('planContentPieceProfileWindow', () => {
const from = '2026-08-01T03:00:00.000Z'
const to = '2026-09-21T03:00:00.000Z'
it('keeps the window novelties, newest first, and classifies each link-only kind', () => {
const plan = planContentPieceProfileWindow({
posts: [
post('NOVO'),
post('CARROSSEL', { mediaType: 'CAROUSEL_ALBUM', mediaUrl: null }),
post('PROTEGIDO', { mediaUrl: null }),
],
from,
to,
})
expect(plan.found).toBe(3)
expect(plan.existingCount).toBe(0)
expect(plan.outsideWindow).toBe(0)
expect(plan.malformed).toBe(0)
expect(plan.window).toEqual({ from, to })
expect(plan.candidates.map((candidate) => candidate.shortcode)).toEqual([
'NOVO',
'CARROSSEL',
'PROTEGIDO',
])
expect(plan.candidates.map((candidate) => candidate.linkOnlyReason)).toEqual([
null,
'carrossel',
'indisponivel',
])
})
it('drops what falls outside the window, the malformed and the feed twins', () => {
const plan = planContentPieceProfileWindow({
posts: [
post('JA', { permalink: 'https://www.instagram.com/reel/JA/' }),
post('ANTES', { timestamp: '2026-07-31T23:59:59.000Z' }),
post('DEPOIS', { timestamp: '2026-09-22T00:00:00.000Z' }),
post('SEMID', { permalink: 'https://example.com/no-id/' }),
post('REPETIDA'),
post('REPETIDA', { permalink: 'https://www.instagram.com/p/REPETIDA/' }),
],
existingSourceUrls: ['https://www.instagram.com/p/JA/'],
from,
to,
})
expect(plan.found).toBe(3)
expect(plan.existingCount).toBe(1)
expect(plan.existingShortcodes).toEqual(['JA'])
expect(plan.outsideWindow).toBe(2)
expect(plan.malformed).toBe(1)
expect(plan.feedDuplicates).toBe(1)
expect(plan.candidates.map((candidate) => candidate.shortcode)).toEqual(['REPETIDA'])
})
it('treats an unbounded window as the whole feed (the recency mode)', () => {
const plan = planContentPieceProfileWindow({
posts: [
post('ANTIGA', { timestamp: '2020-01-01T00:00:00.000Z' }),
post('NOVA', { timestamp: '2026-09-20T12:00:00.000Z' }),
],
})
expect(plan.window).toEqual({ from: null, to: null })
expect(plan.found).toBe(2)
expect(plan.outsideWindow).toBe(0)
expect(plan.candidates.map((candidate) => candidate.shortcode)).toEqual(['ANTIGA', 'NOVA'])
})
it('refuses an invalid bound', () => {
expect(() => planContentPieceProfileWindow({ posts: [], from: 'ontem' })).toThrow(/janela/)
expect(() => planContentPieceProfileWindow({ posts: [], to: 'amanhã' })).toThrow(/janela/)
})
})
describe('contentPieceProfilePeriodError', () => {
const today = '2026-09-29'
it('accepts a real, ordered, non-future period', () => {
expect(contentPieceProfilePeriodError({ since: '2026-08-01', today })).toBeNull()
expect(
contentPieceProfilePeriodError({ since: '2026-08-01', until: '2026-08-31', today }),
).toBeNull()
expect(contentPieceProfilePeriodError({ since: today, today })).toBeNull()
})
it('rejects a malformed or impossible date', () => {
expect(contentPieceProfilePeriodError({ since: '01/08/2026', today })).toBe(
'Informe uma data inicial válida.',
)
expect(contentPieceProfilePeriodError({ since: '2026-02-30', today })).toBe(
'Informe uma data inicial válida.',
)
expect(
contentPieceProfilePeriodError({ since: '2026-08-01', until: '2026-02-30', today }),
).toBe('Informe uma data final válida.')
})
it('rejects an inverted or future period', () => {
expect(
contentPieceProfilePeriodError({ since: '2026-08-31', until: '2026-08-01', today }),
).toBe('A data final não pode ser anterior à data inicial.')
expect(contentPieceProfilePeriodError({ since: '2026-10-01', today })).toBe(
'A data inicial não pode estar no futuro.',
)
})
})
describe('contentPieceProfilePeriodBounds', () => {
it('resolves the start of the since day and the exclusive end of the until day in Bahia', () => {
expect(contentPieceProfilePeriodBounds({ since: '2026-08-01' })).toEqual({
fromIso: '2026-08-01T03:00:00.000Z',
toIso: null,
})
expect(contentPieceProfilePeriodBounds({ since: '2026-08-01', until: '2026-08-31' })).toEqual({
fromIso: '2026-08-01T03:00:00.000Z',
toIso: '2026-09-01T03:00:00.000Z',
})
})
it('refuses an impossible date defensively', () => {
expect(() => contentPieceProfilePeriodBounds({ since: '2026-13-01' })).toThrow(
'Informe uma data inicial válida.',
)
})
})
describe('contentPieceProfileWindowStartReached', () => {
const fromIso = '2026-08-01T03:00:00.000Z'
it('is reached when the walk got a post older than the window start', () => {
expect(
contentPieceProfileWindowStartReached({
posts: [post('NOVA'), post('ANTES', { timestamp: '2026-07-31T23:59:59.000Z' })],
fromIso,
}),
).toBe(true)
})
it('is not reached when every post is still inside the window', () => {
expect(contentPieceProfileWindowStartReached({ posts: [post('NOVA')], fromIso })).toBe(false)
expect(contentPieceProfileWindowStartReached({ posts: [], fromIso })).toBe(false)
})
it('is reached when there is no bound to reach', () => {
expect(contentPieceProfileWindowStartReached({ posts: [], fromIso: null })).toBe(true)
})
})
describe('contentPieceProfileWindowLabel', () => {
it('labels the recency and the civil period', () => {
expect(contentPieceProfileWindowLabel({ mode: 'recent' })).toBe('Recentes')
expect(contentPieceProfileWindowLabel({ mode: 'period', since: '2026-08-01' })).toBe(
'01/08/2026 → hoje',
)
expect(
contentPieceProfileWindowLabel({
mode: 'period',
since: '2026-08-01',
until: '2026-08-31',
}),
).toBe('01/08/2026 → 31/08/2026')
})
})
describe('contentPieceProfileFeedIdentityUrls', () => {
it('spells every canonical kind of each parseable post once', () => {
expect(
contentPieceProfileFeedIdentityUrls([
post('A'),
post('B', { permalink: 'https://www.instagram.com/p/B/' }),
post('A', { permalink: 'https://www.instagram.com/p/A/' }),
post('X', { permalink: 'https://example.com/x/' }),
]),
).toEqual([
'https://www.instagram.com/p/A/',
'https://www.instagram.com/reel/A/',
'https://www.instagram.com/tv/A/',
'https://www.instagram.com/p/B/',
'https://www.instagram.com/reel/B/',
'https://www.instagram.com/tv/B/',
])
})
})

View file

@ -2,6 +2,11 @@
import { describe, expect, it } from 'vitest'
import {
contentPieceProfileFeedIdentityUrls,
planContentPieceProfileWindow,
} from '@/lib/contentPieceProfileWindow'
import {
formatInstagramContentReport,
instagramContentFeedIdentityUrls,
@ -85,81 +90,12 @@ describe('content:instagram:import arguments', () => {
})
})
describe('planInstagramContentWindow', () => {
const from = '2026-08-01T00:00:00.000Z'
const to = '2026-09-30T00:00:00.000Z'
it('keeps the window novelties, newest first, and classifies each link-only kind', () => {
const plan = planInstagramContentWindow({
posts: [
post('NOVO'),
post('CARROSSEL', { mediaType: 'CAROUSEL_ALBUM', mediaUrl: null }),
post('PROTEGIDO', { mediaUrl: null }),
],
existingSourceUrls: [],
from,
to,
})
expect(plan.found).toBe(3)
expect(plan.existingCount).toBe(0)
expect(plan.outsideWindow).toBe(0)
expect(plan.malformed).toBe(0)
expect(plan.candidates).toEqual([
{
url: 'https://www.instagram.com/reel/NOVO/',
shortcode: 'NOVO',
linkOnlyReason: null,
timestamp: '2026-09-20T12:00:00.000Z',
mediaType: 'REEL',
},
expect.objectContaining({ shortcode: 'CARROSSEL', linkOnlyReason: 'carrossel' }),
expect.objectContaining({ shortcode: 'PROTEGIDO', linkOnlyReason: 'indisponivel' }),
])
})
it('drops what the Central already carries in ANY spelling and counts the rest', () => {
const plan = planInstagramContentWindow({
posts: [
post('JA', { permalink: 'https://www.instagram.com/reel/JA/' }),
post('FORA', { timestamp: '2026-07-01T00:00:00.000Z' }),
post('SEMID', { permalink: 'https://example.com/no-id/' }),
post('REPETIDA'),
post('REPETIDA', { permalink: 'https://www.instagram.com/p/REPETIDA/' }),
],
// The catalogue carries the same post under the `p` kind.
existingSourceUrls: ['https://www.instagram.com/p/JA/'],
from,
to,
})
expect(plan.found).toBe(3)
expect(plan.existingCount).toBe(1)
expect(plan.existingShortcodes).toEqual(['JA'])
expect(plan.outsideWindow).toBe(1)
expect(plan.malformed).toBe(1)
expect(plan.feedDuplicates).toBe(1)
expect(plan.candidates.map((candidate) => candidate.shortcode)).toEqual(['REPETIDA'])
})
})
describe('instagramContentFeedIdentityUrls', () => {
it('spells every canonical kind of each parseable post once', () => {
const urls = instagramContentFeedIdentityUrls([
post('A'),
post('B', { permalink: 'https://www.instagram.com/p/B/' }),
post('A', { permalink: 'https://www.instagram.com/p/A/' }),
post('X', { permalink: 'https://example.com/x/' }),
])
expect(urls).toEqual([
'https://www.instagram.com/p/A/',
'https://www.instagram.com/reel/A/',
'https://www.instagram.com/tv/A/',
'https://www.instagram.com/p/B/',
'https://www.instagram.com/reel/B/',
'https://www.instagram.com/tv/B/',
])
describe('planner re-exports (C235)', () => {
it('keeps the CLI names bound to the single owner in src/lib', () => {
// The window policy is pinned by its owner spec; here only the delegation
// contract of the CLI shell matters.
expect(planInstagramContentWindow).toBe(planContentPieceProfileWindow)
expect(instagramContentFeedIdentityUrls).toBe(contentPieceProfileFeedIdentityUrls)
})
})

View file

@ -339,6 +339,35 @@ describe('loadInstagramFeed', () => {
expect(mediaCalls[1]).toContain('limit=3')
})
it('caps the walk at 20 pages by default and follows a deeper window (C235)', async () => {
const pages = 22
const deepFetch = async (input: string) => {
calls.push(input)
if (!input.includes('/media')) return fakeResponse({ username: 'depjorgesolla' })
const after = /after=page-(\d+)/.exec(input)?.[1]
const pageIndex = after ? Number(after) : 0
const next = pageIndex + 1
return fakeResponse({
data: [MEDIA_ITEM(`post${pageIndex}`)],
...(next < pages ? { paging: { cursors: { after: `page-${next}` } } } : {}),
})
}
calls.length = 0
const capped = await loadInstagramFeed({ ...args, maxResults: 100, fetchImpl: deepFetch })
expect(capped.posts).toHaveLength(20)
calls.length = 0
// A 1100-media window needs 22 pages of 50 — the ceiling follows it.
const raised = await loadInstagramFeed({
...args,
maxResults: 1_100,
fetchImpl: deepFetch,
})
expect(raised.posts).toHaveLength(pages)
expect(calls.filter((call) => call.includes('/media'))).toHaveLength(pages)
})
it('stops at the page that holds the searched post and never asks a deeper cursor', async () => {
calls.length = 0
const result = await loadInstagramFeed({