From 8608aeb317755cba66005236527c06045c9f62a1 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 29 Sep 2026 12:11:04 -0300 Subject: [PATCH] =?UTF-8?q?feat(C235):=20importa=20qualquer=20janela=20de?= =?UTF-8?q?=20publica=C3=A7=C3=B5es=20do=20perfil=20na=20Central?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/changelog/2026-09-29-c235.md | 1 + .../central-conteudos-importar-janela-impl.md | 180 +++++ scripts/lib/instagramContentPlan.mjs | 111 +-- .../comunicacao/conteudos/importar/route.ts | 4 +- .../(app)/comunicacao/conteudos/types.ts | 4 +- .../campanha/actions/contentPieces.ts | 15 +- .../ImportContentPieceProfileDialog.tsx | 750 +++++++++++++++--- src/lib/campaignTime.ts | 23 + src/lib/contentPieceProfileWindow.ts | 235 ++++++ src/lib/schemas/contentPiece.ts | 35 +- .../content/contentPieceProfileImport.ts | 171 ++-- src/utilities/socialFeed/instagramFeed.ts | 18 +- tests/int/contentPiece.int.spec.ts | 166 +++- tests/unit/campaignTimeCivilDate.unit.spec.ts | 13 + .../contentPieceProfileWindow.unit.spec.ts | 223 ++++++ tests/unit/instagramContentPlan.unit.spec.ts | 86 +- tests/unit/instagramFeed.unit.spec.ts | 29 + 17 files changed, 1696 insertions(+), 368 deletions(-) create mode 100644 docs/changelog/2026-09-29-c235.md create mode 100644 docs/plans/central-conteudos-importar-janela-impl.md create mode 100644 src/lib/contentPieceProfileWindow.ts create mode 100644 tests/unit/contentPieceProfileWindow.unit.spec.ts diff --git a/docs/changelog/2026-09-29-c235.md b/docs/changelog/2026-09-29-c235.md new file mode 100644 index 00000000..0a857235 --- /dev/null +++ b/docs/changelog/2026-09-29-c235.md @@ -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`. diff --git a/docs/plans/central-conteudos-importar-janela-impl.md b/docs/plans/central-conteudos-importar-janela-impl.md new file mode 100644 index 00000000..7c59488a --- /dev/null +++ b/docs/plans/central-conteudos-importar-janela-impl.md @@ -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 ()` 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
page.tsx intocada"] --> D["Dialog
recentes (default) · período"] + D -->|"recentes · clique único"| A["POST .../conteudos/importar
{} ou {mode:'recent'}"] + D -->|"período · prévia"| B["POST .../conteudos/importar
{mode:'period', since, until?}"] + A --> F["contentPieceProfileImport
global fail-closed · loadInstagramFeed"] + B --> G["scan por cursor + shouldStopAt
teto de páginas derivado do maxResults (10K) · timeout 120s"] + F --> H["planner puro (src/lib)
janela · dedupe por identidade"] + G --> H + H --> R["found · existingCount · candidates · truncated"] + R -->|"período"| K["confirmação: novas · já estavam · total
aviso 'Janela grande' > 50"] + K -->|"Importar N publicações"| L["loop 1 POST por candidato
.../importar/criar (C230 intacto)"] + R -->|"recentes"| L + L --> REC["recibo honesto
+ 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), `` 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. diff --git a/scripts/lib/instagramContentPlan.mjs b/scripts/lib/instagramContentPlan.mjs index 90f6338c..b3b36ebb 100644 --- a/scripts/lib/instagramContentPlan.mjs +++ b/scripts/lib/instagramContentPlan.mjs @@ -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 diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route.ts index de05a10d..ba693928 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route.ts +++ b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route.ts @@ -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({ status: 'success', ...listing, diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/types.ts b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/types.ts index 19e4a7a2..a7974595 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/types.ts +++ b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/types.ts @@ -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 } diff --git a/src/app/(campaign)/campanha/actions/contentPieces.ts b/src/app/(campaign)/campanha/actions/contentPieces.ts index d65aad17..e601330f 100644 --- a/src/app/(campaign)/campanha/actions/contentPieces.ts +++ b/src/app/(campaign)/campanha/actions/contentPieces.ts @@ -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 => { - const { payload, actor } = await getCampaignActionContext() +export const listContentPieceProfileImportCandidatesForActor = async ( + input: unknown = {}, +): Promise => { + 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 diff --git a/src/components/campaign/content/ImportContentPieceProfileDialog.tsx b/src/components/campaign/content/ImportContentPieceProfileDialog.tsx index 23250af2..82ce3c95 100644 --- a/src/components/campaign/content/ImportContentPieceProfileDialog.tsx +++ b/src/components/campaign/content/ImportContentPieceProfileDialog.tsx @@ -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 + 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('intro') - const [listing, setListing] = useState(false) + const [phase, setPhase] = useState('window') + const [mode, setMode] = useState('recent') + const [since, setSince] = useState('') + const [until, setUntil] = useState('') + const [windowError, setWindowError] = useState(null) + const [searching, setSearching] = useState(false) + const [listing, setListing] = useState(null) const [progress, setProgress] = useState({ done: 0, total: 0 }) const [result, setResult] = useState(null) const [error, setError] = useState(null) + const recentRadioRef = useRef(null) + const periodRadioRef = useRef(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) => { + 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( - 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( 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( + 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 = ( + <> + +
+
+ Importar do perfil + + Escolha quais publicações de {PROFILE_HANDLE} procurar. + Só as que ainda não estão na Central entram, sempre como rascunho. + +
+ +
+

Qual janela de publicações?

+

+ O recorte usa a data de publicação do post. +

+ +
+
+ +
+ +
+ + + {mode === 'period' ? ( +
+
+ + +
+
+ + + +
+

+ Deixe “Até” em branco para buscar até hoje. +

+
+ ) : null} +
+
+ + {windowError ? ( +

+ {windowError} +

+ ) : null} +
+ +
+

+

+

+

+

+

+
+ + + + + + + ) return ( Importação concluída {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.'} + {result.windowLabel ? ( +

{result.windowLabel}

+ ) : null} -
+ {result.truncated ? ( +
+
+ ) : null} + +
{result.created} novas criadas @@ -232,7 +596,7 @@ export const ImportContentPieceProfileDialog = ({
{linkOnlyRows.length > 0 || result.failed > 0 ? ( -
+
{linkOnlyRows.map(([reason, count], index) => { const status = IMPORT_STATUS[reason] return ( @@ -277,11 +641,11 @@ export const ImportContentPieceProfileDialog = ({
) : null} - + - + ) : phase === 'confirm' && listing ? ( + <> + +
+
+ Importar do perfil + + A janela escolhida tem {listing.found}{' '} + {plural(listing.found, 'publicação', 'publicações')}. Confira o tamanho antes de + criar os rascunhos. + +
+ +
+
+
+ +
+
+ + {listing.candidates.length} + + novas a criar +
+
+ {listing.existingCount} + já estavam +
+
+ {listing.found} + total na janela +
+
+ + {largeWindow ? ( +
+
+ ) : null} + +

+ Reexecutar a mesma janela não duplica: o que já está é reconhecido pela identidade + do post. Pode demorar um pouco em janelas grandes. +

+
+ + + + + + + ) : phase === 'empty' ? ( + <> + +
+
+ + Nenhuma publicação neste período + + + Nada de {PROFILE_HANDLE} foi publicado entre{' '} + {formatCivilDateLabel(since)} e{' '} + {until ? formatCivilDateLabel(until) : 'hoje'}. + +
+

+ O recorte está vazio. Confira as datas ou importe as publicações recentes do perfil. +

+ + + + + + ) : phase === 'error' ? ( + <> + +
+
+ Importar do perfil + + {mode === 'period' ? ( + <> + A janela de{' '} + + {contentPieceProfileWindowLabel(periodWindow())} + {' '} + não pôde ser buscada agora. + + ) : ( + <>As publicações recentes não puderam ser buscadas agora. + )} + +
+ + + + + + + ) : phase === 'running' ? ( <> @@ -319,10 +857,10 @@ export const ImportContentPieceProfileDialog = ({
- {listing ? ( + {searching ? ( ) : (
- {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'}

- {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.`}

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

+ {mode === 'period' ? ( + + ) : null} +

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

@@ -363,8 +914,18 @@ export const ImportContentPieceProfileDialog = ({

- 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' ? ( + <> + Pode demorar — 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. + + )}

@@ -375,72 +936,7 @@ export const ImportContentPieceProfileDialog = ({ ) : ( - <> - -
-
- Importar do perfil - - Vamos procurar as publicações recentes de{' '} - {PROFILE_HANDLE} que ainda não estão na Central. - -
- -
-
-
-
-
-
-
- -
- Sem scraping. -
- Só o perfil oficial {PROFILE_HANDLE}, pela API oficial. Carrossel ou mídia sem - arquivo extraível continua como peça-link, com o motivo visível. -
- - {error ? ( - - - ) : null} -
- - - - - - + windowStep )}
diff --git a/src/lib/campaignTime.ts b/src/lib/campaignTime.ts index 6021c342..432a1c05 100644 --- a/src/lib/campaignTime.ts +++ b/src/lib/campaignTime.ts @@ -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. diff --git a/src/lib/contentPieceProfileWindow.ts b/src/lib/contentPieceProfileWindow.ts new file mode 100644 index 00000000..26f7aa01 --- /dev/null +++ b/src/lib/contentPieceProfileWindow.ts @@ -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() + 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() + 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, + } +} diff --git a/src/lib/schemas/contentPiece.ts b/src/lib/schemas/contentPiece.ts index 514eed0b..b15e7ba6 100644 --- a/src/lib/schemas/contentPiece.ts +++ b/src/lib/schemas/contentPiece.ts @@ -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({ diff --git a/src/utilities/content/contentPieceProfileImport.ts b/src/utilities/content/contentPieceProfileImport.ts index 00e48173..e59ec4df 100644 --- a/src/utilities/content/contentPieceProfileImport.ts +++ b/src/utilities/content/contentPieceProfileImport.ts @@ -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 @@ -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> => { - if (identityUrls.length === 0) return new Set() + const unique = [...new Set(identityUrls)] + const catalogued = new Set() - const found = await payload.find({ - collection: 'contentPiece', - where: { sourceUrl: { in: [...new Set(identityUrls)] } }, - depth: 0, - limit: 0, - pagination: false, - select: { sourceUrl: true }, - user: actor, - overrideAccess: false, - }) + 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>> => { + window: ContentPieceProfileWindow + now: Date +}): Promise<{ + feed: Awaited> + 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> 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 => { - 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, + }), } } diff --git a/src/utilities/socialFeed/instagramFeed.ts b/src/utilities/socialFeed/instagramFeed.ts index ad926ca8..afb23efd 100644 --- a/src/utilities/socialFeed/instagramFeed.ts +++ b/src/utilities/socialFeed/instagramFeed.ts @@ -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), diff --git a/tests/int/contentPiece.int.spec.ts b/tests/int/contentPiece.int.spec.ts index 01d805a1..1690eddf 100644 --- a/tests/int/contentPiece.int.spec.ts +++ b/tests/int/contentPiece.int.spec.ts @@ -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', diff --git a/tests/unit/campaignTimeCivilDate.unit.spec.ts b/tests/unit/campaignTimeCivilDate.unit.spec.ts index 4ab79f5d..470773c4 100644 --- a/tests/unit/campaignTimeCivilDate.unit.spec.ts +++ b/tests/unit/campaignTimeCivilDate.unit.spec.ts @@ -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) }) diff --git a/tests/unit/contentPieceProfileWindow.unit.spec.ts b/tests/unit/contentPieceProfileWindow.unit.spec.ts new file mode 100644 index 00000000..7b360d3f --- /dev/null +++ b/tests/unit/contentPieceProfileWindow.unit.spec.ts @@ -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/', + ]) + }) +}) diff --git a/tests/unit/instagramContentPlan.unit.spec.ts b/tests/unit/instagramContentPlan.unit.spec.ts index 83f7584c..491ce834 100644 --- a/tests/unit/instagramContentPlan.unit.spec.ts +++ b/tests/unit/instagramContentPlan.unit.spec.ts @@ -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) }) }) diff --git a/tests/unit/instagramFeed.unit.spec.ts b/tests/unit/instagramFeed.unit.spec.ts index c36ab579..5fc3755c 100644 --- a/tests/unit/instagramFeed.unit.spec.ts +++ b/tests/unit/instagramFeed.unit.spec.ts @@ -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({