mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
feat(C235): importa qualquer janela de publicações do perfil na Central
A importação do perfil oficial deixa a janela fixa de 12 mídias e ganha o recorte por período (desde; até opcional), com prévia honesta (novas · já estavam · total) e confirmação antes de criar; "Recentes" segue o default e o clique único. O planner puro de janela/dedupe passa ao dono em src/lib (CLI re-exporta) e a varredura usa o teto de páginas derivado de maxResults, com early-stop por data e corte denunciado (truncated). Sem migration/Consent; tudo Rascunho; fail-closed de credencial intacto. Design tier: DEGRADED (central-conteudos-importar-janela-ui-design) — port classe-a-classes das cenas 01–08 com sign-off humano explícito (2026-09-29); a crítica de paridade do designer no tier primário não pôde rodar por quota.
This commit is contained in:
parent
fa1bf52ccd
commit
8608aeb317
17 changed files with 1696 additions and 368 deletions
1
docs/changelog/2026-09-29-c235.md
Normal file
1
docs/changelog/2026-09-29-c235.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C235 — Central de Conteúdos: importar qualquer janela de publicações do perfil (2026-09-29):** a importação do perfil oficial (`@depjorgesolla`) deixa de ter só a janela fixa de 12 mídias recentes (C230) e passa a aceitar **qualquer período** — "Recentes" continua o default e o clique único, e "Período" (desde; até opcional, por data de publicação, com atalhos de 30 dias/6 meses/este ano) é gesto explícito que mostra o tamanho honesto antes de criar (**novas · já estavam · total na janela**) e confirma ("Importar N publicações"), com aviso âmbar de "Janela grande" acima de 50 novidades. O recibo continua honesto (**novas / já estavam / falharam com motivo**, peça-link com o porquê) e ganha o aviso de corte quando a via oficial não alcança o início da janela ("o Instagram entrega as 10K mídias mais recentes"; reexecutar continua sem duplicar). Mecânica sem teto artificial: o planner puro de janela/dedupe que vivia no CLI (`scripts/lib/instagramContentPlan.mjs`) passou ao dono em `src/lib/contentPieceProfileWindow.ts` (CLI re-exporta os nomes antigos, pins intocados) e a varredura usa o cursor do feed com **early-stop por data** — o teto defensivo de páginas do `loadInstagramFeed` agora deriva de `maxResults` (piso 20 intacto para board/sync/lookup; a janela de 10K sobe a 200 páginas) e o corte é denunciado por cobertura (`truncated`), nunca em silêncio. Limites civis da Bahia: as datas do formulário viram o início do dia `since` e o fim exclusivo do dia `until` (novos `formatCivilDateLabel`/`subtractBahiaCivilMonths` em `campaignTime.ts`); o request vira união (`{}`/`recent` default | `period` com `since`/`until`), com validação pura (data real, ordem, não-futuro) no schema e no cliente, e o lookup de identidade (`sourceUrl in` nas 3 grafias do post) roda em chunks de 2.000 URLs. Nada de novo em schema/Consent/collection (sem migration); o token segue só no servidor, fail-closed de credencial nas duas pontas e tudo entra como Rascunho. Testes: unit novo do módulo puro (janela com/sem bounds, bounds civis, validação, cobertura, label), pin novo do teto de páginas derivado e do CLI por re-export; int com `loadFeed` fake (período com `shouldStopAt` no limite, fora-da-janela fora, dedupe por variante de kind, `truncated` true/false, recente default 12, período impossível antes do feed, envelope da rota com `{}`/período/inválido). Verificação: `pnpm gate:fast` (451 arquivos, 4984 unit verdes), `pnpm test:int` (109 arquivos, 1068 verdes), lint/typecheck. **Design gate: artefato DEGRADED** (`docs/plans/central-conteudos-importar-janela-ui-design.html`, cenas 01–08 portadas classe-a-classe); a crítica de paridade do `designer` no tier primário (triggers b/c) não pôde rodar nesta sessão por quota (`usage limit reached`) e o humano deu **sign-off explícito** do artefato e do port (2026-09-29), registrado no PR com `Design tier: DEGRADED`.
|
||||
180
docs/plans/central-conteudos-importar-janela-impl.md
Normal file
180
docs/plans/central-conteudos-importar-janela-impl.md
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
# Impl: C235 — Central de Conteúdos — importar qualquer janela de publicações do perfil
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-29
|
||||
Issue: #1386
|
||||
Intenção: docs/plans/central-conteudos-importar-janela.md
|
||||
Appetite restante: herdado (~1–2 dias eng) — sem corte novo: a fatia reusa o cliente do feed, o pipeline C220, o dedupe por identidade e o diálogo C230; backfill total, cursor persistido, agendador e triagem prévia seguem fora (intenção).
|
||||
|
||||
> Modo `--auto` (work-issue): plano nasce aprovado pelo agente. Design UI do gate: `docs/plans/central-conteudos-importar-janela-ui-design.html` — **DEGRADED** (`:7`, `:261-266`, tier fallback; cenas 01–08 cobrem o fluxo).
|
||||
>
|
||||
> **Registro de execução (2026-09-29):** o dispatch do `designer` primário no trigger (b) foi tentado no início da execução e **falhou por quota** (`usage limit reached`); o port foi feito contra o artefato DEGRADED como referência (precedente S16/S18/S34/S40: artefato degradado + crítica de fechamento no tier primário), sem improvisar estrutura visual — todos os estados do fluxo (janela, prévia, progresso, recibo, vazio, falha, corte, mobile) estão nas cenas 01–08. A certificação de fechamento (trigger c) e o sign-off humano continuam obrigatórios: **sem tier primário não há PR** — o fluxo `--auto` para antes do `pnpm push`, comenta a Issue e flipa para `blocked` (D6).
|
||||
>
|
||||
> **Sign-off humano (2026-09-29):** o humano aprovou explicitamente o artefato DEGRADED e o port nesta sessão; a entrega segue para PR **Ready** com `Design tier: DEGRADED (<slug>)` e o registro do sign-off no body, conforme o caminho de `work-issue` com humano presente para `DEGRADED`.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** com a credencial configurada, o operador escolhe a janela da importação do perfil oficial — **recentes** (default, como hoje) ou **período** (desde; até opcional) — vê o tamanho antes de criar em janelas de período (novas · já estavam · total), confirma, e lê o recibo honesto **novas / já estavam / falharam com motivo**; a execução cobre a janela pela via oficial sem teto artificial e, quando a API/limite corta, o recibo diz; dedupe por identidade do post segue soberano (reexecução e janelas sobrepostas não duplicam); tudo entra como Rascunho, nada é publicado.
|
||||
- **O que NÃO negociar:**
|
||||
- Só o perfil próprio (`@depjorgesolla`) pela API oficial (`loadInstagramFeed`, `src/utilities/socialFeed/instagramFeed.ts:253`); sem scraping, terceiro ou stories; `instagramFeed.ts` não importa `@payload-config` (gate de ciclos madge).
|
||||
- Dedupe por identidade do post — `contentPiecePostIdentityUrls` (`src/lib/contentPiece.ts:487`) e `contentPieceExistsForPostIdentity` (`src/utilities/content/contentPieceLink.ts:93`) — sem segundo resolvedor, segunda fila ou segunda identidade.
|
||||
- Fail-closed de credencial nas duas pontas ("Instagram ainda não configurado."); token nunca em log/URL/wire; erro de feed vira mensagem de produto.
|
||||
- Tudo Rascunho; nenhuma publicação automática; `curatedFields` preservado; board/feed da home intocados.
|
||||
- Gate de comunicação `canReadCommunicationCatalog`; `user` + `overrideAccess:false` em todo caminho de request.
|
||||
- Sem migration/collection/Consent novo (nenhum campo/global muda; `push:false` intacto); sem escrita multi-collection nova.
|
||||
- Design **DEGRADED** não autoriza markup: certificação/estouro do designer primário antes da fase 2; sign-off humano antes do push (D6).
|
||||
- **O que reavaliar (hipóteses da intenção, resolvidas no código):**
|
||||
- "`since`/`until` da edge resolvem a janela" — **rejeitada**: documentado mas não provado no host `graph.instagram.com` e sem teste offline; o caminho provado no repo é cursor + `shouldStopAt` (C220/C230/CLI) — D1.
|
||||
- "o teto de 20 páginas do feed comporta a janela" — **não**: `INSTAGRAM_MAX_MEDIA_PAGES = 20` (`instagramFeed.ts:156`) = 1000 posts; cortaria janelas antigas. Vira parâmetro opcional (default intacto) e o importer usa o próprio limite da edge — D1.
|
||||
- "a lógica pura de janela não existe" — **existe** em `scripts/lib/instagramContentPlan.mjs:120` (`planInstagramContentWindow`), e o CLI já faz early-stop por data (`scripts/import-instagram-content.mjs:201-204`); o plano move o dono e o CLI delega — D2.
|
||||
- "o request de listagem é `z.object({})`" (`src/lib/schemas/contentPiece.ts:89`) — vira união com default `recent` para não quebrar o envelope pinado C230 — D3.
|
||||
- "o diálogo C230 cria direto após a busca" (`ImportContentPieceProfileDialog.tsx:107-169`) — período ganha prévia/confirmação; recentes mantém clique único — D4.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P["Central de Conteúdos<br/>page.tsx intocada"] --> D["Dialog<br/>recentes (default) · período"]
|
||||
D -->|"recentes · clique único"| A["POST .../conteudos/importar<br/>{} ou {mode:'recent'}"]
|
||||
D -->|"período · prévia"| B["POST .../conteudos/importar<br/>{mode:'period', since, until?}"]
|
||||
A --> F["contentPieceProfileImport<br/>global fail-closed · loadInstagramFeed"]
|
||||
B --> G["scan por cursor + shouldStopAt<br/>teto de páginas derivado do maxResults (10K) · timeout 120s"]
|
||||
F --> H["planner puro (src/lib)<br/>janela · dedupe por identidade"]
|
||||
G --> H
|
||||
H --> R["found · existingCount · candidates · truncated"]
|
||||
R -->|"período"| K["confirmação: novas · já estavam · total<br/>aviso 'Janela grande' > 50"]
|
||||
K -->|"Importar N publicações"| L["loop 1 POST por candidato<br/>.../importar/criar (C230 intacto)"]
|
||||
R -->|"recentes"| L
|
||||
L --> REC["recibo honesto<br/>+ aviso de corte quando truncated"]
|
||||
```
|
||||
|
||||
**Opções consideradas (geral):** A) janela por cursor walk + `shouldStopAt` com teto parametrizado no cliente do feed, planner puro movido para `src/lib` (sem twinning) e fluxo de diálogo com prévia/confirmação; B) `since`/`until` nativos da Graph API; C) segunda implementação de janela/dedupe dentro do importador com fila/estado próprio.
|
||||
**Recomendação:** **A** — um dono por mecanismo (feed, planner, identidade, pipeline), zero schema novo, testável offline com `fetchImpl`/`loadFeed` injetados e cabe no appetite.
|
||||
**Rejeitadas:** **B** — não testável offline, comportamento real desconhecido no host e contraria o caminho provado; **C** — twinning do dedupe/janela e fila fora de escopo (C212 §Q4).
|
||||
|
||||
### D1 — Mecânica da varredura: cursor + early-stop, teto parametrizado e corte denunciado por cobertura
|
||||
|
||||
**Opções:** A) manter o cursor walk do feed com `shouldStopAt` por timestamp (o mesmo que C220 e o CLI provam), derivar o teto defensivo de páginas do próprio `maxResults` (piso 20 intacto; a janela de 10K sobe o teto para 200 páginas), denunciando corte por **cobertura** (`truncated` quando o feed não é vazio e nenhum post retornado é mais antigo que o `since` pedido); B) `since`/`until` nativos da edge `/{ig-user-id}/media`; C) híbrido (manda `since`/`until` e também pagina com cursor).
|
||||
**Recomendação:** **A** — é o único caminho exercitado e testável sem rede (`fetchImpl` fake pino o walk em `tests/unit/instagramFeed.unit.spec.ts:342-396`); o early-stop para no primeiro post mais antigo que `since` (a página inteira ainda entra, o planner descarta o excedente) e a cobertura cobre os três cortes possíveis — teto do cliente, fim de paginação da API (limite de 10K) e fatia de `maxResults`. `truncated` é inferido no importer (`posts.length > 0 && !covered`), sem novo estado no feed. O early-stop só é composto para período; recentes segue sem ele.
|
||||
**Rejeitadas:** **B** — sem teste offline, o comportamento real de `limit`+cursor no host graph.instagram.com é pergunta aberta (`docs/plans/central-conteudos-varredura-instagram.md:151`) e o cliente do feed é dono do cursor; **C** — dois mecanismos para o mesmo corte = drift e nenhum ganho.
|
||||
|
||||
### D2 — Dono do planner puro: mover para `src/lib`, CLI delega (sem twinning)
|
||||
|
||||
**Opções:** A) mover `planInstagramContentWindow` (`scripts/lib/instagramContentPlan.mjs:120`) e `instagramContentFeedIdentityUrls` (`:92`) para `src/lib/contentPieceProfileWindow.ts` (puro, client-safe), o mjs re-exportar com os nomes antigos e o importador C230 passar a usar o mesmo planner para recentes e período (bounds nulos = sem filtro de data); B) o servidor importar o `.mjs` de `scripts/`; C) reimplementar o filtro dentro de `contentPieceProfileImport.ts`.
|
||||
**Recomendação:** **A** — depth check: a política de janela/dedupe já existe e tem dono; mover para `src/lib` deixa o CLI como shell (parse/report ficam) e o servidor reusa a mesma contagem honesta (`found` = candidatos + já existentes + duplicatas do feed; `outsideWindow`/`malformed` ficam no plano, não no wire). Mantém o shape de retorno do plano idêntico para os pins do CLI (`tests/unit/instagramContentPlan.unit.spec.ts:88-144`); no modo recentes o planner roda sem bounds e a semântica dos pins C230 (`found`/`existingCount`/`candidates`) permanece.
|
||||
**Rejeitadas:** **B** — app/Server Action importando `scripts/**` é superfície errada (build/knip/`server-only`); **C** — vira segunda identidade de dedupe/janela no mesmo módulo (o defeito que o C230 resolveu).
|
||||
|
||||
### D3 — Contrato do request/rota e datas civis
|
||||
|
||||
**Opções:** A) `contentPieceProfileImportRequestSchema` (`src/lib/schemas/contentPiece.ts:89`) vira união: `{}`/`{mode:'recent'}` (default) | `{mode:'period', since:'YYYY-MM-DD', until?:'YYYY-MM-DD'}` com refine puro (forma, data real, `until ≥ since`, `since ≤ hoje` BRT); o range civil vira instantes com `parseBahiaDateTimeInput` (`src/lib/campaignTime.ts:83`) — início do dia `T00:00`, fim do dia `T23:59` + 59,999 s, `until` ausente = agora; a action re-parseia (precedente C230) e o diálogo valida antes com o mesmo helper para copy/desabilitar; B) `mode` obrigatório; C) `since`/`until` como instantes ISO no wire.
|
||||
**Recomendação:** **A** — o default do produto continua um body vazio (o pin do envelope C230 em `tests/int/contentPiece.int.spec.ts:1852-1905` segue verde), `<input type="date">` fala `YYYY-MM-DD` e o operador pensa em dia civil da Bahia, não em instante. Sem nova safe message: período inválido cai no genérico (o diálogo impede antes; o servidor é defesa em profundidade). `criar/route.ts` e `contentPieceLinkRequestSchema` intocados.
|
||||
**Rejeitadas:** **B** — quebra o pin `{}` e o default sem ganho; **C** — instante no wire empurra conversão de fuso para o cliente e ignora o input nativo.
|
||||
|
||||
### D4 — Fluxo do diálogo: recentes clique único; período prévia + confirmação
|
||||
|
||||
**Opções:** A) recentes mantém o clique único de hoje (lista e cria no mesmo gesto, janela 12); período ganha prévia com contagens ("novas a criar · já estavam · total na janela"), aviso âmbar "Janela grande" quando `candidates > CONTENT_PIECE_PROFILE_IMPORT_LARGE_WINDOW_THRESHOLD` (50 = uma página do feed), botões "Importar N publicações"/"Trocar"/"Voltar", e os estados da cena 06 (vazio, falha com "Tentar de novo", corte no recibo); B) período cria direto; C) prévia para tudo, inclusive recentes.
|
||||
**Recomendação:** **A** — o recorte por período é gesto explícito e o design fixa o tamanho antes de criar (cenas 02/03); a operação diária não muda; `truncated` aparece no recibo (cena 06d) e um período sem candidatos nem vira botão "Importar 0" (receita cena 05 adaptada com "Nenhuma novidade na janela"). O limiar de "janela grande" vive como constante no módulo puro (client-safe), revisável por gatilho.
|
||||
**Rejeitadas:** **B** — despejo acidental de rascunhos que o produto quer evitar; **C** — fricção nova no fluxo diário.
|
||||
|
||||
### D5 — Tempo limite e feedback da busca
|
||||
|
||||
**Opções:** A) recentes mantém 30 s (`CONTENT_PIECE_PROFILE_IMPORT_FEED_TIMEOUT_MS`, `contentPieceProfileImport.ts:43`); período ganha orçamento maior (120 s) para o walk de muitas páginas; spinner sem progresso de páginas; B) timeout único alto para os dois modos; C) progresso por página/streaming.
|
||||
**Recomendação:** **A** — `AbortSignal.timeout` já cobre todas as chamadas do walk e o período pode custar dezenas de fetches; estourou, cai na mensagem honesta de retry existente e nada foi criado (a criação só começa depois da prévia). A UI não conhece o total de páginas — progresso de busca seria inventado; o "pode demorar" do design é o feedback.
|
||||
**Rejeitadas:** **B** — uma API pendurada seguraria o recente por 2 min sem motivo; **C** — progresso inventado (não há total antes de varrer).
|
||||
|
||||
### D6 — Design gate: DEGRADED ⇒ certificação antes do markup e sign-off antes do push
|
||||
|
||||
**Opções:** A) tratar o artefato como gate duro: o designer primário certifica/estende as cenas **antes** de qualquer markup da fase 2; a crítica final certificada + sign-off humano ficam registrados no PR (`Design tier:`), e o DEGRADED sozinho **não** vai ao push; B) seguir o DEGRADED como aprovado; C) redesenhar do zero.
|
||||
**Recomendação:** **A** — a doutrina do repo só permite port classe-a-classe sob artefato certificado; DEGRADED é explicitamente "design NÃO certificado; requer sign-off humano" (`design html:261-266`). **Execução:** o designer primário estava indisponível (quota) no trigger (b); o port seguiu o artefato como referência (precedente S16/S18/S34/S40) e a certificação fica no fechamento (trigger c) — se o tier primário não certificar, o fluxo para antes do push (sem PR; Issue `blocked`). Estados não desenhados que a execução encontrar → designer estende antes do markup.
|
||||
**Rejeitadas:** **B** — viola a disciplina de design; **C** — as cenas 01–08 já cobrem o fluxo; estender é mais barato.
|
||||
|
||||
### D7 — Testes: unit do puro + int com deps injetadas; e2e fica fora
|
||||
|
||||
**Opções:** A) unit do módulo puro (janela com/sem bounds, cobertura/`truncated`, bounds civis, validação, limiar) e pin novo do teto derivado de `maxResults` no feed; int do `contentPieceProfileImport` com `loadFeed` fake (recent/period, `shouldStopAt` no limite, `truncated`, dedupe por identidade, fail-closed, erro honesto) e envelope da rota; e2e sem spec nova; B) e2e novo com stub do Instagram; C) só int.
|
||||
**Recomendação:** **A** — mesma razão do C230: a fronteira é coberta com dep injetada, sem credencial real e sem estado compartilhado do `social-feed-settings` entre projetos; pins C230/C220/CLI seguem verdes e o diff acorda `campaignSpeechAcervo` pelo manifesto (`src/lib/schemas/contentPiece.ts` é risk prefix mapeado, `:575-577`; `src/components/campaign/content` e `src/utilities/content`, `:552-558`; o arquivo novo casa o prefixo `src/lib/contentPiece`, `:574`).
|
||||
**Rejeitadas:** **B** — flake e custo sem cobrir o que o int cobre; **C** — a política de janela (a parte mais fácil de errar) ficaria sem pin barato.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/contentPieceProfileWindow.ts`** (novo, puro, client-safe; não importa `utilities/`): dono do recorte. Move `planContentPieceProfileWindow` (de `instagramContentPlan.mjs:120`; `from`/`to` opcionais/null = sem filtro) e `contentPieceProfileFeedIdentityUrls` (`:92`); tipos `ContentPieceProfileWindowInput` (`{mode:'recent'} | {mode:'period'; since; until?}`); `contentPieceProfilePeriodError({since, until, today})` (forma `YYYY-MM-DD`, data real por round-trip `formatBahiaCivilDate`, ordem, `since` não-futuro); `contentPieceProfilePeriodBounds({window, now})` → `{fromIso, toIso}` compondo `parseBahiaDateTimeInput` (`campaignTime.ts:83`); `contentPieceProfileWindowCovered({posts, fromMs})`; `contentPieceProfileWindowLabel` (compõe `formatCivilDateLabel`, `campaignTime.ts`, para "01/08/2026 → hoje"); `CONTENT_PIECE_PROFILE_IMPORT_LARGE_WINDOW_THRESHOLD = 50`.
|
||||
- **`src/lib/campaignTime.ts`**: ganha `formatCivilDateLabel` (`aaaa-mm-dd` → `dd/mm/aaaa`) e `subtractBahiaCivilMonths` (clamp de fim de mês) — donos puros dos atalhos de janela do diálogo.
|
||||
- **`scripts/lib/instagramContentPlan.mjs`**: passa a importar e re-exportar o planner/identidades com os nomes antigos (`planInstagramContentWindow`, `instagramContentFeedIdentityUrls`) — CLI (`import-instagram-content.mjs:55/236`), unit (`instagramContentPlan.unit.spec.ts`) e guards (`instagramContentImportCli.unit.spec.ts`) intocados. Args/report/summary ficam.
|
||||
- **`src/utilities/socialFeed/instagramFeed.ts`**: o teto defensivo de páginas passa a **derivar de `maxResults`** (`max(INSTAGRAM_MAX_MEDIA_PAGES, ceil(maxResults / INSTAGRAM_MAX_RESULTS_CAP))`) — janela de 12 e o lookup/board ficam idênticos (piso 20); só a janela de 10K do período amplia o walk. `loadInstagramFeed` não aprende `since`/`until`.
|
||||
- **`src/utilities/content/contentPieceProfileImport.ts`**: `listContentPieceProfileImportCandidates({payload, actor, window = {mode:'recent'}, loadFeed?, fetchImpl?, now?})`; recentes = hoje (`maxResults: CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW = 12`, timeout 30 s); período = `maxResults: CONTENT_PIECE_PROFILE_IMPORT_SCAN_LIMIT = 10_000`, `shouldStopAt` por `timestamp < fromMs`, timeout `CONTENT_PIECE_PROFILE_IMPORT_PERIOD_FEED_TIMEOUT_MS = 120_000`; lookup de identidade em chunks de 2.000 URLs via `contentPieceProfileFeedIdentityUrls`; retorno ganha `truncated` (inferido por `contentPieceProfileWindowStartReached`). `createContentPieceFromProfilePost` (`:205`) e o fail-closed (`:110-146`) intocados.
|
||||
- **`src/lib/schemas/contentPiece.ts`**: `contentPieceProfileImportRequestSchema` vira união com default recente + refine de período via `contentPieceProfilePeriodError`; safe messages existentes bastam.
|
||||
- **`src/app/(campaign)/campanha/actions/contentPieces.ts`**: `listContentPieceProfileImportCandidatesForActor(input = {})` re-parseia e repassa `window`; gate fresco intocado (`:388-395`).
|
||||
- **`.../conteudos/importar/route.ts`**: handler passa o body parseado à action; envelope/safe messages inalterados. **`.../importar/criar/route.ts`** intocada.
|
||||
- **`.../conteudos/types.ts`**: `ContentPieceProfileImportCandidatesResponse` ganha `truncated: boolean` (aditivo).
|
||||
- **`src/components/campaign/content/ImportContentPieceProfileDialog.tsx`**: seletor de janela (radiogroup cena 01/02: "Recentes · Padrão" default; "Período" com Desde/Até nativos + chips "Últimos 30 dias"/"Últimos 6 meses"/"Este ano", via `subtractBahiaCivilDays`/`formatBahiaCivilDate`), prévia+confirmação (cena 03), progresso real (cena 04, barra com `aria-valuenow`), recibo com a linha da janela + aviso de corte (cenas 05/06d), vazio (06a) e falha com "Tentar de novo" (06b); mantém loop sequencial, bloqueio de fechar em running e `router.refresh()`.
|
||||
- **`page.tsx`** (lista): intocada — toolbar/banner/empty da C230 já cobrem o fail-closed (cena 06c).
|
||||
- **`docs/changelog/2026-09-29-c235.md`** (novo).
|
||||
|
||||
**Migration: sem migration** — nenhuma collection/global/field muda; `push:false` intacto; sem `generate:types` (tipos novos são de aplicação).
|
||||
|
||||
**Access / Consent:** sem Consent (sem PII nova) e sem chave hardcoded; gate fresco `canReadCommunicationCatalog` nas actions (como as irmãs); leitura do global via Local API sem `user` (bypass admin já documentado em `contentPieceProfileImport.ts:119-121`) só para o boolean/credencial, e o listing com `user: actor` + `overrideAccess:false` (`:83-84`). Sem escrita multi-collection nova: a fase A só lê; a fase B é o `payload.create` único já transacional do C230.
|
||||
|
||||
**UI:** Impeccable **C** (encaixe no diálogo existente). Shells/componentes reusados: `Dialog/Alert/Badge/Button/Spinner`, `postCampaignJson`, `buildContentPieceListHref` (`contentPieceListUrl.ts:89-90`). **Trigger de design:** DEGRADED → certificação/estouro do designer primário antes do markup (D6); estados não desenhados param a execução.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
N/A — operação/curadoria, não analytics: o tamanho da janela e o recibo são contagem operacional (novas/já estavam/falhas) e detalhe textual (motivo da peça-link), sem agregado, taxa, série, engajamento ou alcance. A intenção fixou "sem números do Instagram nesta tela" (métricas de post são C213, fora).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server — quota ~50%:** módulo puro (`contentPieceProfileWindow.ts`) + re-export do CLI; teto de páginas derivado de `maxResults` no feed; `contentPieceProfileImport` com janela/truncated; schema/route/action/types. **Tracer bullet cedo:** int com `loadFeed` fake — período devolve `found`/`existingCount`/`candidates` deduplicados por identidade e `truncated` correto com o `shouldStopAt` pinado no limite; recentes segue com `receivedMaxResults === 12` e os pins C230 verdes; CLI spec verde pelo re-export. Só então a UI.
|
||||
2. **UI — quota ~30%:** port das cenas 01–08 no diálogo, **após certificação do designer** (D6). Prova: build/typecheck e revisão no browser; sem tocar `page.tsx`.
|
||||
3. **Gates — quota ~20%:** `pnpm gate:fast`; `pnpm test:int`; e2e selecionado/curado (o diff acorda `campaignSpeechAcervo` pelo risk mapping; nenhuma spec e2e nova); crítica final do designer certificada + sign-off humano (DEGRADED) com `Design tier:` no PR; `pnpm push` via GitHub.
|
||||
|
||||
### Testes previstos
|
||||
|
||||
- **`tests/unit/contentPieceProfileWindow.unit.spec.ts` (novo):** planner com bounds (dentro/fora, `outsideWindow`, duplicata de feed, identidade já catalogada em outra grafia), bounds nulos (modo recentes) e ordenação newest-first; `contentPieceProfilePeriodError` (formato, data impossível 2026-02-30, `until < since`, `since` futuro, válido); `contentPieceProfilePeriodBounds` (início/fim do dia civil BRT, `until` ausente = agora); `contentPieceProfileWindowCovered` (post mais antigo que `fromMs` = coberto; só mais novos = corte; vazio); limiar e label.
|
||||
- **`tests/unit/instagramFeed.unit.spec.ts` (estender sem quebrar pins):** uma janela mais profunda (`maxResults: 1_100`) anda 22 páginas; default segue 20 (pins de clamp/one-page/walk/early-stop/refresh intactos).
|
||||
- **`tests/int/contentPiece.int.spec.ts` — novo describe C235 (deps injetadas, sem rede):** período com `loadFeed` fake — `receivedMaxResults === SCAN_LIMIT` e `shouldStopAt(postAntigo) === true`/`shouldStopAt(postNaJanela) === false`; posts fora da janela não entram; dedupe por variante de kind (`/p/` no banco × `/reel/` no feed); `truncated: true` quando o feed não alcança o `since`, `false` quando alcança; período sem credencial → fail-closed sem chamar `loadFeed`; feed lançando → mensagem honesta; envelope da rota com `{}` (recente) e com body de período; período inválido → 400 sem escrita.
|
||||
- **Pins que seguem intocados:** C230 (`:1653-1929`, janela 12 em `:1696`), C220 (`:1214-1230`), `instagramContentPlan.unit.spec.ts` e `instagramContentImportCli.unit.spec.ts` via re-export.
|
||||
- **e2e — não entra.** Mesma razão do C230: sem credencial real e o global é estado compartilhado; o int cobre a fronteira. **Gatilho:** regressão do envelope HTTP que o int não pegar → e2e HTTP curto com o stub (`tests/e2e/instagram-stub.mjs`), semeando/restaurando o global em `finally`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Backfill total/"importa as 10K de uma vez"** — a janela é escolhida; o corte é dito e reexecutar continua sem duplicar; varredura automática é C212 §Q4.
|
||||
- **Cursor persistido/retomada/painel de varredura** — nada de estado próprio; o recibo é a verdade.
|
||||
- **`since`/`until` nativos da edge** — fora (D1); não ensinar isso ao `loadInstagramFeed`.
|
||||
- **Segunda identidade/fila/resolvedor; agendador/webhook; auto-publicar; triagem peça a peça; analytics/stories** — permanentes (intenção/C211/C213).
|
||||
- **Progresso de busca por página, scroll infinito de seleção, edição em massa** — fora.
|
||||
- **Tocar board/feed da home, snapshot/cache/kill switch/lock do `SocialFeedSettings`, outras rotas da Central** — o diff é zero fora do listado.
|
||||
- **Mexer no `instagramFeed.ts` além do teto de páginas derivado** — sem `since`/`until`, sem novo retorno.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Janela antiga = muitas páginas → rate limit/timeout:** o walk respeita `AbortSignal` de 120 s; estourou, mensagem honesta e nada criado (criação só após a prévia); reexecutar converge. Se virar rotina, o item é a varredura com cursor (C212 §Q4).
|
||||
- **`truncated` por cobertura tem falso positivo quando o perfil inteiro está dentro da janela** (conta nasceu depois do `since`): o aviso é conservador ("importamos o que foi possível neste recorte") e nunca esconde corte; gatilho: se incomodar, o feed devolve o motivo do fim do walk (`stopReason`) e o flag refina.
|
||||
- **Página do feed com menos de 50 itens:** o teto de páginas pode cortar antes dos 10K, mas a cobertura denuncia `truncated` — honesto.
|
||||
- **Mover o planner quebra o CLI/pins:** re-export com os nomes antigos; `pnpm gate:fast` (knip/cycles/unit) pega qualquer órfão.
|
||||
- **Datas impossíveis normalizadas pelo parse** (`2026-02-30` → março): validação por round-trip em `contentPieceProfilePeriodError` no schema e no cliente.
|
||||
- **Divergência prévia × criação (corrida):** o probe da fase B responde `existing` e o recibo fecha nas categorias; candidato criado por outro ator conta em "já estavam".
|
||||
- **Design DEGRADED:** gate duro — sem certificação não há markup; sem sign-off humano não há push.
|
||||
- **`page.tsx`/board/feed:** intocados por construção; `loadInstagramFeed` só ganha o teto derivado.
|
||||
|
||||
## Triage do /simplify (2026-09-29)
|
||||
|
||||
Dois revisores (estrutural + qualidade) rodaram sobre o diff; os fixes baratos já estão na entrega (preprocess do schema restrito a `{}` — fim do downgrade silencioso de `{since,until}` sem `mode`; teto de páginas derivado de `maxResults` em vez de arg público; `contentPieceProfileWindowStartReached` no lugar de "covered"; tipo do post reusando `ContentPieceProfilePost`; pins de `formatCivilDateLabel`/`subtractBahiaCivilMonths`; spec do CLI reduzido a pin de re-export; `role="alert"` redundante e `setSearching(false)` no-op removidos; radios com roving tabindex/setas; labels de data sem `aria-label` divergente; `Number.isFinite` redundante no early-stop).
|
||||
|
||||
- **Já resolvido (não reabrir):** os fixes acima; o `maxMediaPages` público removido e os pins atualizados.
|
||||
- **Adiado com gatilho:**
|
||||
- **Fatia do diálogo em steps locais** (`ImportContentPiecePreviewDialog` tem a cadeia de fases em JSX): gatilho — a certificação de design mexer na estrutura do diálogo ou um passo novo entrar; hoje a estrutura é o port fiel do artefato.
|
||||
- **`loadConfiguredInstagramFeed`** acumula fail-closed + validação/bounds + composição + persistência: a parte pura já tem dono (`contentPieceProfileWindow.ts`); gatilho — 2º consumidor do mapeamento janela→`maxResults`/timeout/early-stop.
|
||||
- **`truncated` conservador** (S5): gatilho — o feed devolver `stopReason` (ver Riscos).
|
||||
- **e2e do diálogo** (S6): gatilho — regressão do envelope HTTP que o int não pegar (ver Testes).
|
||||
- **Explicitamente fora:** `formatCivilDateLabel` privado do `activityAllDay.ts` (recebe instante ISO, semântica distinta — não é duplicata); tipos de wire importados de `app/(campaign)/.../types` em client components (padrão do repo, precedente C230).
|
||||
- **Registro adiado:** não há Issue nova neste fechamento — o fluxo para antes do PR (design DEGRADED); o gatilho da fatia do diálogo fica aqui e o débito pode virar `C235-FOLLOWUP-DRY` (`depends: 1386`) quando o fechamento landar.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: recentes default preservado; período com desde/até opcional cobre a janela pedida pela via oficial; prévia com tamanho antes de criar em período; recibo honesto (novas/já estavam/falharam + motivo) e aviso de corte quando a via oficial não alcança; dedupe por identidade (sobreposição/reexecução/link manual sem gêmeas); nada publicado; fail-closed de credencial; só perfil próprio/API oficial; token nunca em log.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: gate fresco `canReadCommunicationCatalog`; `user` + `overrideAccess:false` (bypass admin só o já documentado do global); **sem migration/collection/Consent novo**; sem escrita multi-collection nova; `lib/` não importa `utilities/` e o módulo novo é puro/client-safe; `instagramFeed.ts` não importa `@payload-config`; um dono por mecanismo (planner movido, sem segundo dedupe/fila/identidade); board/feed da home e pipeline C220 intocados.
|
||||
- [ ] Testes de domínio previstos (unit/int) verdes e pins C230/C220/CLI verdes; nenhuma spec e2e nova; manifest sem edição e seleção não-zero.
|
||||
- [ ] UI apenas após certificação do designer; crítica final + sign-off humano (DEGRADED) com `Design tier:` no PR antes do push.
|
||||
- [ ] `pnpm gate:fast` verde; `pnpm test:int` verde; e2e selecionado/curado verde; `pnpm push` via GitHub.
|
||||
|
||||
## Self-score
|
||||
|
||||
**Self-score decision-quality: 4/5.** (1) Decisões caras com Opções/Recomendação/Rejeitadas explícitas (mecânica da varredura, dono do planner, contrato/datas, fluxo do diálogo, timeout, gate de design, testes); (2) cabe no appetite — move a pura, adiciona um arg opcional ao feed e porta cenas no diálogo existente, sem schema/infra; (3) rabbit holes nomeados (backfill, cursor, `since/until`, segunda fila, agendador, analytics, progresso inventado); (4) depth check: planner e identidade movidos para o dono em `src/lib`, CLI delega, dedupe segue num predicado único e o cliente do feed só parametriza o teto; (5) aceite de produto intacto. Não 5/5 por três incertezas assumidas: (a) o falso positivo conservador de `truncated` na cobertura; (b) o limiar de "Janela grande" (50) inferido do design/mock; (c) o design **DEGRADED**, cuja certificação é pré-condição da fase 2 e não está sob controle da execução.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ export const POST = campaignJsonMutationRoute(
|
|||
safeMessages: CONTENT_PIECE_PROFILE_IMPORT_SAFE_MESSAGES,
|
||||
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
|
||||
},
|
||||
async () => {
|
||||
const listing = await listContentPieceProfileImportCandidatesForActor()
|
||||
async (body) => {
|
||||
const listing = await listContentPieceProfileImportCandidatesForActor(body)
|
||||
return NextResponse.json<ContentPieceProfileImportCandidatesResponse>({
|
||||
status: 'success',
|
||||
...listing,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
|
||||
contentPieceDeleteRequestSchema,
|
||||
contentPieceLinkRequestSchema,
|
||||
contentPieceProfileImportRequestSchema,
|
||||
contentPiecePublicationRequestSchema,
|
||||
contentPieceRetryRequestSchema,
|
||||
contentPieceStatusRequestSchema,
|
||||
|
|
@ -385,14 +386,16 @@ export const addContentPieceByLinkForActor = async (input: {
|
|||
* Central by the identity of the post, never by the URL spelling. The listing
|
||||
* persists nothing; the confirmation creates the pieces one by one.
|
||||
*/
|
||||
export const listContentPieceProfileImportCandidatesForActor =
|
||||
async (): Promise<ContentPieceProfileImportListing> => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
export const listContentPieceProfileImportCandidatesForActor = async (
|
||||
input: unknown = {},
|
||||
): Promise<ContentPieceProfileImportListing> => {
|
||||
const window = contentPieceProfileImportRequestSchema.parse(input)
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
|
||||
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
|
||||
|
||||
return listContentPieceProfileImportCandidates({ payload, actor })
|
||||
}
|
||||
return listContentPieceProfileImportCandidates({ payload, actor, window })
|
||||
}
|
||||
|
||||
/**
|
||||
* C230 — creates ONE draft from ONE listed media through the C220 pipeline
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
'use client'
|
||||
|
||||
import { CheckIcon, CircleAlertIcon, InstagramIcon } from 'lucide-react'
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
CalendarXIcon,
|
||||
CheckIcon,
|
||||
CircleAlertIcon,
|
||||
InstagramIcon,
|
||||
SearchIcon,
|
||||
TriangleAlertIcon,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState, type KeyboardEvent } from 'react'
|
||||
|
||||
import type {
|
||||
ContentPieceProfileImportCandidatesResponse,
|
||||
|
|
@ -21,13 +29,27 @@ import {
|
|||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Progress } from '@/components/ui/Progress'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import {
|
||||
CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_CREATE_HREF,
|
||||
CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_HREF,
|
||||
} from '@/lib/campaignPaths'
|
||||
import {
|
||||
formatBahiaCivilDate,
|
||||
formatCivilDateLabel,
|
||||
subtractBahiaCivilDays,
|
||||
subtractBahiaCivilMonths,
|
||||
} from '@/lib/campaignTime'
|
||||
import type { ContentPieceProfileLinkOnlyReason } from '@/lib/contentPiece'
|
||||
import {
|
||||
CONTENT_PIECE_PROFILE_IMPORT_LARGE_WINDOW_THRESHOLD,
|
||||
contentPieceProfilePeriodError,
|
||||
contentPieceProfileWindowLabel,
|
||||
type ContentPieceProfileWindow,
|
||||
} from '@/lib/contentPieceProfileWindow'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buildContentPieceListHref } from '@/utilities/content/contentPieceListUrl'
|
||||
|
||||
|
|
@ -35,7 +57,9 @@ const GENERIC_ERROR_MESSAGE = 'Não foi possível concluir a importação. Tente
|
|||
|
||||
const PROFILE_HANDLE = '@depjorgesolla'
|
||||
|
||||
type ImportPhase = 'intro' | 'running' | 'done'
|
||||
type ImportPhase = 'window' | 'confirm' | 'running' | 'done' | 'empty' | 'error'
|
||||
|
||||
type WindowMode = 'recent' | 'period'
|
||||
|
||||
type ImportResult = {
|
||||
created: number
|
||||
|
|
@ -45,8 +69,14 @@ type ImportResult = {
|
|||
linkOnly: ContentPieceProfileLinkOnlyReason[]
|
||||
/** First non-generic failure message of the loop, shown in the receipt. */
|
||||
failureMessage: string | null
|
||||
/** The official API did not reach the window start (design scene 06d). */
|
||||
truncated: boolean
|
||||
/** "Período · 01/08/2026 → hoje" in period mode; null for the recency slice. */
|
||||
windowLabel: string | null
|
||||
}
|
||||
|
||||
type ImportListing = Extract<ContentPieceProfileImportCandidatesResponse, { status: 'success' }>
|
||||
|
||||
const IMPORT_STATUS: Record<
|
||||
ContentPieceProfileLinkOnlyReason,
|
||||
{ title: string; description: string }
|
||||
|
|
@ -72,13 +102,17 @@ const countByReason = (
|
|||
return [...counts]
|
||||
}
|
||||
|
||||
const plural = (count: number, singular: string, pluralForm: string): string =>
|
||||
count === 1 ? singular : pluralForm
|
||||
|
||||
/**
|
||||
* C230 — "Importar do perfil" (approved design scenes 2–4): the assessoria
|
||||
* brings the recent publications of the official profile into the Central.
|
||||
* The dialog drives the two phases for real — the listing (Graph API, own
|
||||
* profile only, dedupe by post identity) and one creation per novelty through
|
||||
* the C220 pipeline — so "Importando X de N…" is progress, never decoration.
|
||||
* Nothing is published: every novelty lands as Rascunho for curation.
|
||||
* C230/C235 — "Importar do perfil": the assessoria brings the publications of
|
||||
* the official profile into the Central. The dialog drives the two phases for
|
||||
* real — the listing (Graph API, own profile only, dedupe by post identity)
|
||||
* and one creation per novelty through the C220 pipeline. C235 adds the window
|
||||
* choice: Recents stays the default single-click gesture; a period is an
|
||||
* explicit gesture that shows the honest size (new · already there · total)
|
||||
* before creating. Nothing is published: every novelty lands as Rascunho.
|
||||
*/
|
||||
export const ImportContentPieceProfileDialog = ({
|
||||
configured,
|
||||
|
|
@ -90,55 +124,72 @@ export const ImportContentPieceProfileDialog = ({
|
|||
}) => {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [phase, setPhase] = useState<ImportPhase>('intro')
|
||||
const [listing, setListing] = useState(false)
|
||||
const [phase, setPhase] = useState<ImportPhase>('window')
|
||||
const [mode, setMode] = useState<WindowMode>('recent')
|
||||
const [since, setSince] = useState('')
|
||||
const [until, setUntil] = useState('')
|
||||
const [windowError, setWindowError] = useState<string | null>(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [listing, setListing] = useState<ImportListing | null>(null)
|
||||
const [progress, setProgress] = useState({ done: 0, total: 0 })
|
||||
const [result, setResult] = useState<ImportResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const recentRadioRef = useRef<HTMLButtonElement>(null)
|
||||
const periodRadioRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const today = formatBahiaCivilDate(new Date())
|
||||
|
||||
const selectWindowMode = (next: WindowMode) => {
|
||||
setMode(next)
|
||||
setWindowError(null)
|
||||
}
|
||||
|
||||
// WAI-ARIA radiogroup: arrows move the selection and the focus (roving
|
||||
// tabindex), so the two window options behave like one control.
|
||||
const handleWindowChoiceKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'].includes(event.key)) return
|
||||
event.preventDefault()
|
||||
const next = mode === 'recent' ? 'period' : 'recent'
|
||||
selectWindowMode(next)
|
||||
;(next === 'recent' ? recentRadioRef : periodRadioRef).current?.focus()
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setPhase('intro')
|
||||
setListing(false)
|
||||
setPhase('window')
|
||||
setMode('recent')
|
||||
setSince('')
|
||||
setUntil('')
|
||||
setWindowError(null)
|
||||
setSearching(false)
|
||||
setListing(null)
|
||||
setProgress({ done: 0, total: 0 })
|
||||
setResult(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const runImport = async () => {
|
||||
const periodWindow = (): ContentPieceProfileWindow => ({
|
||||
mode: 'period',
|
||||
since,
|
||||
until: until || undefined,
|
||||
})
|
||||
|
||||
const requestWindow = (): ContentPieceProfileWindow =>
|
||||
mode === 'recent' ? { mode: 'recent' } : periodWindow()
|
||||
|
||||
const requestLabel = (request: ContentPieceProfileWindow): string | null =>
|
||||
request.mode === 'period' ? `Período · ${contentPieceProfileWindowLabel(request)}` : null
|
||||
|
||||
const runCreationLoop = async (window: ContentPieceProfileWindow, listed: ImportListing) => {
|
||||
setPhase('running')
|
||||
setListing(true)
|
||||
setProgress({ done: 0, total: 0 })
|
||||
setResult(null)
|
||||
setError(null)
|
||||
|
||||
let candidates: ContentPieceProfileImportCandidatesResponse & { status: 'success' }
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<ContentPieceProfileImportCandidatesResponse>(
|
||||
CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_HREF,
|
||||
{},
|
||||
)
|
||||
if (!ok || payload.status !== 'success') {
|
||||
setError(payload.status === 'error' ? payload.message : GENERIC_ERROR_MESSAGE)
|
||||
setPhase('intro')
|
||||
return
|
||||
}
|
||||
candidates = payload
|
||||
} catch {
|
||||
setError(GENERIC_ERROR_MESSAGE)
|
||||
setPhase('intro')
|
||||
return
|
||||
}
|
||||
|
||||
setListing(false)
|
||||
setProgress({ done: 0, total: candidates.candidates.length })
|
||||
setProgress({ done: 0, total: listed.candidates.length })
|
||||
|
||||
let created = 0
|
||||
let existing = candidates.existingCount
|
||||
let existing = listed.existingCount
|
||||
let failed = 0
|
||||
let failureMessage: string | null = null
|
||||
const linkOnly: ContentPieceProfileLinkOnlyReason[] = []
|
||||
|
||||
for (const [index, candidate] of candidates.candidates.entries()) {
|
||||
for (const [index, candidate] of listed.candidates.entries()) {
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<ContentPieceProfileImportCreateResponse>(
|
||||
CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_CREATE_HREF,
|
||||
|
|
@ -160,15 +211,311 @@ export const ImportContentPieceProfileDialog = ({
|
|||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
setProgress({ done: index + 1, total: candidates.candidates.length })
|
||||
setProgress({ done: index + 1, total: listed.candidates.length })
|
||||
}
|
||||
|
||||
setResult({ created, existing, failed, linkOnly, failureMessage })
|
||||
setResult({
|
||||
created,
|
||||
existing,
|
||||
failed,
|
||||
linkOnly,
|
||||
failureMessage,
|
||||
truncated: listed.truncated,
|
||||
windowLabel: requestLabel(window),
|
||||
})
|
||||
setPhase('done')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const runSearch = async () => {
|
||||
const request = requestWindow()
|
||||
if (request.mode === 'period') {
|
||||
const message = contentPieceProfilePeriodError({
|
||||
since: request.since,
|
||||
until: request.until ?? null,
|
||||
today,
|
||||
})
|
||||
if (message) {
|
||||
setWindowError(message)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setWindowError(null)
|
||||
setError(null)
|
||||
setListing(null)
|
||||
setPhase('running')
|
||||
setSearching(true)
|
||||
setProgress({ done: 0, total: 0 })
|
||||
|
||||
let found: ImportListing
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<ContentPieceProfileImportCandidatesResponse>(
|
||||
CAMPAIGN_CONTENT_PIECE_PROFILE_IMPORT_HREF,
|
||||
request,
|
||||
)
|
||||
if (!ok || payload.status !== 'success') {
|
||||
setError(payload.status === 'error' ? payload.message : GENERIC_ERROR_MESSAGE)
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
found = payload
|
||||
} catch {
|
||||
setError(GENERIC_ERROR_MESSAGE)
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
setListing(found)
|
||||
setSearching(false)
|
||||
|
||||
if (request.mode === 'recent') {
|
||||
await runCreationLoop(request, found)
|
||||
return
|
||||
}
|
||||
if (found.found === 0) {
|
||||
setPhase('empty')
|
||||
return
|
||||
}
|
||||
if (found.candidates.length === 0) {
|
||||
setResult({
|
||||
created: 0,
|
||||
existing: found.existingCount,
|
||||
failed: 0,
|
||||
linkOnly: [],
|
||||
failureMessage: null,
|
||||
truncated: found.truncated,
|
||||
windowLabel: requestLabel(request),
|
||||
})
|
||||
setPhase('done')
|
||||
return
|
||||
}
|
||||
setPhase('confirm')
|
||||
}
|
||||
|
||||
const linkOnlyRows = result ? countByReason(result.linkOnly) : []
|
||||
const progressPercent =
|
||||
progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0
|
||||
const largeWindow =
|
||||
listing !== null &&
|
||||
listing.candidates.length > CONTENT_PIECE_PROFILE_IMPORT_LARGE_WINDOW_THRESHOLD
|
||||
|
||||
const windowStep = (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="mb-4 grid size-10 place-items-center rounded-full bg-red-50 text-primary">
|
||||
<InstagramIcon className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<DialogTitle className="border-b-0 pb-0">Importar do perfil</DialogTitle>
|
||||
<DialogDescription>
|
||||
Escolha quais publicações de <b className="text-foreground">{PROFILE_HANDLE}</b> procurar.
|
||||
Só as que ainda não estão na Central entram, sempre como rascunho.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="text-sm font-semibold">Qual janela de publicações?</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
O recorte usa a data de publicação do post.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="mt-3 space-y-2"
|
||||
role="radiogroup"
|
||||
aria-label="Janela de publicações"
|
||||
onKeyDown={handleWindowChoiceKeyDown}
|
||||
>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<button
|
||||
ref={recentRadioRef}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mode === 'recent'}
|
||||
tabIndex={mode === 'recent' ? 0 : -1}
|
||||
onClick={() => selectWindowMode('recent')}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 p-3.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
mode === 'recent' ? 'bg-stone-50' : 'bg-white hover:bg-stone-50',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 size-4 shrink-0 rounded-full border-2',
|
||||
mode === 'recent'
|
||||
? 'border-primary bg-primary ring-2 ring-white ring-inset'
|
||||
: 'border-stone-300',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<b className="text-sm">Recentes</b>
|
||||
<Badge variant="secondary">Padrão</Badge>
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
|
||||
As publicações mais novas do perfil. É o comportamento de sempre.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<button
|
||||
ref={periodRadioRef}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mode === 'period'}
|
||||
tabIndex={mode === 'period' ? 0 : -1}
|
||||
onClick={() => selectWindowMode('period')}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 p-3.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
mode === 'period' ? 'bg-stone-50' : 'bg-white hover:bg-stone-50',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 size-4 shrink-0 rounded-full border-2',
|
||||
mode === 'period'
|
||||
? 'border-primary bg-primary ring-2 ring-white ring-inset'
|
||||
: 'border-stone-300',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="flex items-center gap-2">
|
||||
<b className="text-sm">Período</b>
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
|
||||
Escolha desde uma data; a data final é opcional.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{mode === 'period' ? (
|
||||
<div className="border-t bg-white px-3.5 py-3">
|
||||
<div className="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">Desde</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="mt-1 min-h-11"
|
||||
value={since}
|
||||
max={today}
|
||||
onChange={(event) => {
|
||||
setSince(event.target.value)
|
||||
setWindowError(null)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Até (opcional)
|
||||
</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="mt-1 min-h-11"
|
||||
value={until}
|
||||
max={today}
|
||||
placeholder="dd/mm/aaaa"
|
||||
onChange={(event) => {
|
||||
setUntil(event.target.value)
|
||||
setWindowError(null)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-8 rounded-full border px-2.5 text-xs font-medium hover:bg-stone-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => {
|
||||
setSince(subtractBahiaCivilDays(today, 30))
|
||||
setUntil('')
|
||||
setWindowError(null)
|
||||
}}
|
||||
>
|
||||
Últimos 30 dias
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-8 rounded-full border px-2.5 text-xs font-medium hover:bg-stone-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => {
|
||||
setSince(subtractBahiaCivilMonths(today, 6))
|
||||
setUntil('')
|
||||
setWindowError(null)
|
||||
}}
|
||||
>
|
||||
Últimos 6 meses
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-8 rounded-full border px-2.5 text-xs font-medium hover:bg-stone-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-sm:hidden"
|
||||
onClick={() => {
|
||||
setSince(`${today.slice(0, 4)}-01-01`)
|
||||
setUntil('')
|
||||
setWindowError(null)
|
||||
}}
|
||||
>
|
||||
Este ano
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Deixe “Até” em branco para buscar até hoje.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{windowError ? (
|
||||
<p className="mt-2 text-xs font-medium text-red-700" role="alert">
|
||||
{windowError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 rounded-lg border bg-stone-50 p-3.5 text-xs leading-5 text-muted-foreground">
|
||||
<p className="flex gap-2">
|
||||
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden="true" />
|
||||
<span>
|
||||
<b className="text-foreground">Somente novidades</b> — o que já está é reconhecido, sem
|
||||
cópias.
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex gap-2">
|
||||
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden="true" />
|
||||
<span>
|
||||
<b className="text-foreground">Tudo entra como Rascunho</b> — nada é publicado
|
||||
automaticamente.
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex gap-2">
|
||||
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden="true" />
|
||||
<span>
|
||||
<b className="text-foreground">Só o perfil oficial, pela API oficial</b> — sem scraping.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="max-sm:flex-col-reverse">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 max-sm:w-full"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
reset()
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="button" className="min-h-11 max-sm:w-full" onClick={() => void runSearch()}>
|
||||
<SearchIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Buscar publicações
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
|
@ -209,12 +556,29 @@ export const ImportContentPieceProfileDialog = ({
|
|||
<DialogTitle className="border-b-0 pb-0">Importação concluída</DialogTitle>
|
||||
<DialogDescription>
|
||||
{result.created === 0 && result.failed === 0
|
||||
? 'Nenhuma novidade na janela recente — a Central já estava em dia.'
|
||||
? `Nenhuma novidade na ${
|
||||
result.windowLabel ? 'janela escolhida' : 'janela recente'
|
||||
} — a Central já estava em dia.`
|
||||
: 'As novidades já estão na Central como rascunho. Nada foi publicado.'}
|
||||
</DialogDescription>
|
||||
{result.windowLabel ? (
|
||||
<p className="text-xs text-muted-foreground">{result.windowLabel}</p>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{result.truncated ? (
|
||||
<div className="mt-4 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3.5 text-xs leading-5 text-amber-900">
|
||||
<TriangleAlertIcon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<div>
|
||||
<b className="text-sm">A janela pedida é maior do que a via oficial alcança.</b> O
|
||||
Instagram entrega as <b>10K mídias mais recentes</b>; importamos o que foi
|
||||
possível neste recorte. Reexecutar a mesma janela continua de onde parou, sem
|
||||
duplicar.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
||||
<b className="block text-xl tabular-nums text-green-800">{result.created}</b>
|
||||
<span className="text-xs font-medium text-green-800">novas criadas</span>
|
||||
|
|
@ -232,7 +596,7 @@ export const ImportContentPieceProfileDialog = ({
|
|||
</div>
|
||||
|
||||
{linkOnlyRows.length > 0 || result.failed > 0 ? (
|
||||
<div className="overflow-hidden rounded-lg border text-xs">
|
||||
<div className="mt-4 overflow-hidden rounded-lg border text-xs">
|
||||
{linkOnlyRows.map(([reason, count], index) => {
|
||||
const status = IMPORT_STATUS[reason]
|
||||
return (
|
||||
|
|
@ -277,11 +641,11 @@ export const ImportContentPieceProfileDialog = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="max-sm:flex-col-reverse">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
className="min-h-11 max-sm:w-full"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
reset()
|
||||
|
|
@ -289,7 +653,7 @@ export const ImportContentPieceProfileDialog = ({
|
|||
>
|
||||
Fechar
|
||||
</Button>
|
||||
<Button asChild className="min-h-11">
|
||||
<Button asChild className="min-h-11 max-sm:w-full">
|
||||
<Link
|
||||
href={buildContentPieceListHref({ page: 1, statuses: ['rascunho'] }, 1)}
|
||||
onClick={() => {
|
||||
|
|
@ -302,6 +666,180 @@ export const ImportContentPieceProfileDialog = ({
|
|||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : phase === 'confirm' && listing ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="mb-4 grid size-10 place-items-center rounded-full bg-red-50 text-primary">
|
||||
<InstagramIcon className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<DialogTitle className="border-b-0 pb-0">Importar do perfil</DialogTitle>
|
||||
<DialogDescription>
|
||||
A janela escolhida tem <b className="text-foreground">{listing.found}</b>{' '}
|
||||
{plural(listing.found, 'publicação', 'publicações')}. Confira o tamanho antes de
|
||||
criar os rascunhos.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-stone-50 px-3.5 py-2.5 text-xs">
|
||||
<CalendarDaysIcon
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-medium">Período</span>
|
||||
<span className="text-muted-foreground">
|
||||
{contentPieceProfileWindowLabel(periodWindow())}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto text-xs font-medium text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setPhase('window')}
|
||||
>
|
||||
Trocar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
||||
<b className="block text-xl tabular-nums text-green-800">
|
||||
{listing.candidates.length}
|
||||
</b>
|
||||
<span className="text-xs font-medium text-green-800">novas a criar</span>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-stone-50 p-3">
|
||||
<b className="block text-xl tabular-nums">{listing.existingCount}</b>
|
||||
<span className="text-xs font-medium text-muted-foreground">já estavam</span>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<b className="block text-xl tabular-nums">{listing.found}</b>
|
||||
<span className="text-xs font-medium text-muted-foreground">total na janela</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{largeWindow ? (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3.5 text-xs leading-5 text-amber-900">
|
||||
<TriangleAlertIcon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<div>
|
||||
<b className="text-sm">Janela grande.</b> {listing.candidates.length} rascunhos
|
||||
serão criados de uma vez. Nada é publicado — tudo entra como rascunho para
|
||||
revisão na lista.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="rounded-lg bg-muted/70 p-3 text-xs leading-5 text-muted-foreground">
|
||||
Reexecutar a mesma janela não duplica: o que já está é reconhecido pela identidade
|
||||
do post. Pode demorar um pouco em janelas grandes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="max-sm:flex-col-reverse">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 max-sm:w-full"
|
||||
onClick={() => setPhase('window')}
|
||||
>
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 max-sm:w-full"
|
||||
onClick={() => void runCreationLoop(periodWindow(), listing)}
|
||||
>
|
||||
Importar {listing.candidates.length}{' '}
|
||||
{plural(listing.candidates.length, 'publicação', 'publicações')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : phase === 'empty' ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="mb-4 grid size-10 place-items-center rounded-full border bg-stone-50 text-stone-600">
|
||||
<CalendarXIcon className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<DialogTitle className="border-b-0 pb-0">
|
||||
Nenhuma publicação neste período
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Nada de <b className="text-foreground">{PROFILE_HANDLE}</b> foi publicado entre{' '}
|
||||
<b className="text-foreground">{formatCivilDateLabel(since)}</b> e{' '}
|
||||
<b className="text-foreground">{until ? formatCivilDateLabel(until) : 'hoje'}</b>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="mt-4 rounded-lg border bg-stone-50 p-3.5 text-xs leading-5 text-muted-foreground">
|
||||
O recorte está vazio. Confira as datas ou importe as publicações recentes do perfil.
|
||||
</p>
|
||||
<DialogFooter className="max-sm:flex-col-reverse">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 max-sm:w-full"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
reset()
|
||||
}}
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 max-sm:w-full"
|
||||
onClick={() => setPhase('window')}
|
||||
>
|
||||
Escolher outra janela
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : phase === 'error' ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="mb-4 grid size-10 place-items-center rounded-full bg-red-50 text-primary">
|
||||
<InstagramIcon className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<DialogTitle className="border-b-0 pb-0">Importar do perfil</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'period' ? (
|
||||
<>
|
||||
A janela de{' '}
|
||||
<b className="text-foreground">
|
||||
{contentPieceProfileWindowLabel(periodWindow())}
|
||||
</b>{' '}
|
||||
não pôde ser buscada agora.
|
||||
</>
|
||||
) : (
|
||||
<>As publicações recentes não puderam ser buscadas agora.</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Alert variant="destructive" className="mt-4 py-2">
|
||||
<CircleAlertIcon aria-hidden="true" />
|
||||
<AlertDescription className="text-xs">
|
||||
<b>Não foi possível buscar as publicações.</b>{' '}
|
||||
{error ?? 'A API oficial não respondeu.'} Nada foi criado.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DialogFooter className="max-sm:flex-col-reverse">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 max-sm:w-full"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
reset()
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 max-sm:w-full"
|
||||
onClick={() => void runSearch()}
|
||||
>
|
||||
Tentar de novo
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : phase === 'running' ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
|
|
@ -319,10 +857,10 @@ export const ImportContentPieceProfileDialog = ({
|
|||
<div
|
||||
className={cn(
|
||||
'mt-0.5 grid size-6 shrink-0 place-items-center rounded-full',
|
||||
listing ? 'bg-stone-100 text-stone-500' : 'bg-green-100 text-green-700',
|
||||
searching ? 'bg-stone-100 text-stone-500' : 'bg-green-100 text-green-700',
|
||||
)}
|
||||
>
|
||||
{listing ? (
|
||||
{searching ? (
|
||||
<Spinner className="size-3.5" />
|
||||
) : (
|
||||
<CheckIcon className="size-3.5" aria-hidden="true" />
|
||||
|
|
@ -330,29 +868,42 @@ export const ImportContentPieceProfileDialog = ({
|
|||
</div>
|
||||
<div>
|
||||
<b className="text-sm">
|
||||
{listing
|
||||
? 'Buscando as publicações recentes…'
|
||||
: 'Publicações recentes encontradas'}
|
||||
{searching
|
||||
? mode === 'period'
|
||||
? 'Buscando as publicações do período…'
|
||||
: 'Buscando as publicações recentes…'
|
||||
: mode === 'period' && listing
|
||||
? `Janela encontrada · ${listing.found} avaliadas`
|
||||
: 'Publicações recentes encontradas'}
|
||||
</b>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{listing
|
||||
{searching
|
||||
? `Consultando ${PROFILE_HANDLE} pela API oficial.`
|
||||
: `A busca foi feita em ${PROFILE_HANDLE} pela API oficial.`}
|
||||
: mode === 'period' && listing
|
||||
? `${listing.candidates.length} novas · ${listing.existingCount} já estavam. A busca foi feita em ${PROFILE_HANDLE} pela API oficial.`
|
||||
: `A busca foi feita em ${PROFILE_HANDLE} pela API oficial.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!listing && progress.total > 0 ? (
|
||||
{!searching && progress.total > 0 ? (
|
||||
<>
|
||||
<div className="my-3 ml-3 h-5 border-l border-dashed" />
|
||||
<div className="flex items-start gap-3">
|
||||
<Spinner className="mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<b className="text-sm">
|
||||
Importando {Math.min(progress.done + 1, progress.total)} de {progress.total}
|
||||
…
|
||||
</b>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{mode === 'period' ? (
|
||||
<Progress
|
||||
className="mt-2 h-2"
|
||||
value={progressPercent}
|
||||
aria-label="Progresso da importação"
|
||||
/>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
Criando apenas as novidades como rascunho e reconhecendo o que já estava na
|
||||
Central.
|
||||
</p>
|
||||
|
|
@ -363,8 +914,18 @@ export const ImportContentPieceProfileDialog = ({
|
|||
</div>
|
||||
|
||||
<p className="rounded-lg bg-muted/70 p-3 text-xs leading-5 text-muted-foreground">
|
||||
Algumas mídias podem levar mais tempo para serem preparadas. O resultado final separa
|
||||
novas, já existentes e falhas — nenhuma falha fica escondida.
|
||||
{mode === 'period' ? (
|
||||
<>
|
||||
<b className="text-foreground">Pode demorar</b> — a janela é grande e cada mídia é
|
||||
preparada uma a uma. O resultado final separa novas, já existentes e falhas;
|
||||
nenhuma falha fica escondida.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Algumas mídias podem levar mais tempo para serem preparadas. O resultado final
|
||||
separa novas, já existentes e falhas — nenhuma falha fica escondida.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<DialogFooter>
|
||||
|
|
@ -375,72 +936,7 @@ export const ImportContentPieceProfileDialog = ({
|
|||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="mb-4 grid size-10 place-items-center rounded-full bg-red-50 text-primary">
|
||||
<InstagramIcon className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<DialogTitle className="border-b-0 pb-0">Importar do perfil</DialogTitle>
|
||||
<DialogDescription>
|
||||
Vamos procurar as publicações recentes de{' '}
|
||||
<b className="text-foreground">{PROFILE_HANDLE}</b> que ainda não estão na Central.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3 rounded-lg border p-3.5">
|
||||
<CheckIcon className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden="true" />
|
||||
<div>
|
||||
<b className="text-sm">Somente novidades da janela recente</b>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
Publicações que já estão na Central serão reconhecidas e não criam cópias.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 rounded-lg border p-3.5">
|
||||
<CheckIcon className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden="true" />
|
||||
<div>
|
||||
<b className="text-sm">Tudo entra como Rascunho</b>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
Nada é publicado automaticamente. A assessoria revisa e decide o que publicar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-stone-50 p-4 text-xs leading-5 text-muted-foreground">
|
||||
<b className="text-foreground">Sem scraping.</b>
|
||||
<br />
|
||||
Só o perfil oficial {PROFILE_HANDLE}, pela API oficial. Carrossel ou mídia sem
|
||||
arquivo extraível continua como peça-link, com o motivo visível.
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="py-2" role="alert">
|
||||
<CircleAlertIcon aria-hidden="true" />
|
||||
<AlertDescription className="text-xs">{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
reset()
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="button" className="min-h-11" onClick={() => void runImport()}>
|
||||
Buscar publicações
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
windowStep
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
235
src/lib/contentPieceProfileWindow.ts
Normal file
235
src/lib/contentPieceProfileWindow.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import {
|
||||
formatBahiaCivilDate,
|
||||
formatCivilDateLabel,
|
||||
parseBahiaDateTimeInput,
|
||||
} from '@/lib/campaignTime'
|
||||
import {
|
||||
contentPiecePostIdentityUrls,
|
||||
contentPieceProfileCandidateFromPost,
|
||||
parseContentPieceLink,
|
||||
type ContentPieceProfileLinkOnlyReason,
|
||||
type ContentPieceProfilePost,
|
||||
} from '@/lib/contentPiece'
|
||||
|
||||
/**
|
||||
* C235 — the publication window of the profile import: the pure owner of the
|
||||
* recency filter (each post timestamp against the civil Bahia dates the
|
||||
* operator typed), the identity dedupe (the feed against the Central in any
|
||||
* URL spelling and against itself) and the honest coverage check that tells
|
||||
* when the official API did not reach the requested start. No I/O and no
|
||||
* `server-only`: the server importer and the ops CLI both read this, and the
|
||||
* unit specs pin the policy without Payload.
|
||||
*/
|
||||
|
||||
/** The window the operator chose: the default recency slice or a civil period. */
|
||||
export type ContentPieceProfileWindow =
|
||||
| { mode: 'recent' }
|
||||
| { mode: 'period'; since: string; until?: string | null }
|
||||
|
||||
/** The feed post the window filter needs: the shared metadata + its timestamp. */
|
||||
export type ContentPieceProfileWindowPost = ContentPieceProfilePost & { timestamp: string }
|
||||
|
||||
export type ContentPieceProfileWindowCandidate = {
|
||||
url: string
|
||||
shortcode: string
|
||||
linkOnlyReason: ContentPieceProfileLinkOnlyReason | null
|
||||
timestamp: string
|
||||
mediaType: string
|
||||
}
|
||||
|
||||
export type ContentPieceProfileWindowPlan = {
|
||||
/** The bounds actually applied; null means unbounded on that side. */
|
||||
window: { from: string | null; to: string | null }
|
||||
/** Parseable posts inside the window: candidates + already catalogued + feed twins. */
|
||||
found: number
|
||||
existingCount: number
|
||||
existingShortcodes: string[]
|
||||
feedDuplicates: number
|
||||
malformed: number
|
||||
outsideWindow: number
|
||||
/** The novelties, in feed order (newest first). */
|
||||
candidates: ContentPieceProfileWindowCandidate[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Above this many novelties the confirmation warns that a large batch of
|
||||
* drafts will be created at once (design scene 03). One feed page is the unit
|
||||
* the operator already sees in the recency import.
|
||||
*/
|
||||
export const CONTENT_PIECE_PROFILE_IMPORT_LARGE_WINDOW_THRESHOLD = 50
|
||||
|
||||
type CivilDateCheck = { since: string; until?: string | null }
|
||||
|
||||
/** Exact civil-round-trip: a syntactically valid but impossible date fails. */
|
||||
const isRealCivilDate = (value: string): boolean => {
|
||||
const instantIso = parseBahiaDateTimeInput(`${value}T00:00`)
|
||||
return instantIso !== null && formatBahiaCivilDate(new Date(instantIso)) === value
|
||||
}
|
||||
|
||||
/**
|
||||
* The domain message of an invalid period window, or null when it is valid.
|
||||
* `today` is a civil Bahia date so the future check is stable and testable.
|
||||
*/
|
||||
export const contentPieceProfilePeriodError = ({
|
||||
since,
|
||||
until,
|
||||
today,
|
||||
}: CivilDateCheck & { today: string }): string | null => {
|
||||
if (!isRealCivilDate(since)) return 'Informe uma data inicial válida.'
|
||||
if (until && !isRealCivilDate(until)) return 'Informe uma data final válida.'
|
||||
if (until && until < since) return 'A data final não pode ser anterior à data inicial.'
|
||||
if (since > today) return 'A data inicial não pode estar no futuro.'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The instants of the chosen period: the start of the `since` civil day and
|
||||
* the exclusive end of the `until` civil day (Bahia is UTC-3 all year round,
|
||||
* so the next civil midnight is a fixed 24h later). An absent `until` is
|
||||
* open-ended — the feed runs to today.
|
||||
*/
|
||||
export const contentPieceProfilePeriodBounds = ({
|
||||
since,
|
||||
until,
|
||||
}: CivilDateCheck): { fromIso: string; toIso: string | null } => {
|
||||
const fromIso = isRealCivilDate(since) ? parseBahiaDateTimeInput(`${since}T00:00`) : null
|
||||
if (!fromIso) throw new Error('Informe uma data inicial válida.')
|
||||
|
||||
if (!until) return { fromIso, toIso: null }
|
||||
|
||||
const untilStart = isRealCivilDate(until) ? parseBahiaDateTimeInput(`${until}T00:00`) : null
|
||||
if (!untilStart) throw new Error('Informe uma data final válida.')
|
||||
return { fromIso, toIso: new Date(Date.parse(untilStart) + 86_400_000).toISOString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the walk went past the start of the window (the feed is
|
||||
* newest-first, so a post older than `fromIso` proves the window start was
|
||||
* reached). A null bound means unbounded — always reached. The check is
|
||||
* conservative on purpose: a profile whose whole history sits inside the
|
||||
* window is reported as not reached, which only makes the receipt more
|
||||
* cautious, never less honest.
|
||||
*/
|
||||
export const contentPieceProfileWindowStartReached = ({
|
||||
posts,
|
||||
fromIso,
|
||||
}: {
|
||||
posts: readonly ContentPieceProfileWindowPost[]
|
||||
fromIso: string | null
|
||||
}): boolean => {
|
||||
if (fromIso === null) return true
|
||||
const fromMs = Date.parse(fromIso)
|
||||
return posts.some((post) => Date.parse(post.timestamp) < fromMs)
|
||||
}
|
||||
|
||||
/** The pt-BR label of the chosen window ("01/08/2026 → hoje"). */
|
||||
export const contentPieceProfileWindowLabel = (window: ContentPieceProfileWindow): string => {
|
||||
if (window.mode === 'recent') return 'Recentes'
|
||||
const from = formatCivilDateLabel(window.since)
|
||||
return `${from} → ${window.until ? formatCivilDateLabel(window.until) : 'hoje'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity URLs of every parseable post of the feed — what the dedupe
|
||||
* query asks the Central for in `sourceUrl in` lookups. A permalink the
|
||||
* catalogue cannot parse contributes nothing (it never becomes a candidate).
|
||||
*/
|
||||
export const contentPieceProfileFeedIdentityUrls = (
|
||||
posts: readonly ContentPieceProfileWindowPost[],
|
||||
): string[] => {
|
||||
const urls = new Set<string>()
|
||||
for (const post of posts) {
|
||||
const candidate = contentPieceProfileCandidateFromPost(post)
|
||||
if (!candidate) continue
|
||||
const link = parseContentPieceLink(candidate.url)
|
||||
if (!link) continue
|
||||
for (const url of contentPiecePostIdentityUrls(link)) urls.add(url)
|
||||
}
|
||||
return [...urls]
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the feed to the chosen window and splits it into novelties, pieces
|
||||
* the Central already had and posts that cannot be imported (outside the
|
||||
* window, unparseable permalink or a repeated identity inside the feed).
|
||||
* Candidates keep the feed order (newest first). The bounds are optional:
|
||||
* without them the whole feed is the window (the recency import).
|
||||
*
|
||||
* `found` counts the parseable posts inside the window: candidates + already
|
||||
* catalogued + repeated identities.
|
||||
*/
|
||||
export const planContentPieceProfileWindow = ({
|
||||
posts,
|
||||
existingSourceUrls = [],
|
||||
from = null,
|
||||
to = null,
|
||||
}: {
|
||||
posts: readonly ContentPieceProfileWindowPost[]
|
||||
existingSourceUrls?: readonly string[]
|
||||
from?: string | null
|
||||
to?: string | null
|
||||
}): ContentPieceProfileWindowPlan => {
|
||||
const fromMs = from === null ? null : Date.parse(from)
|
||||
const toMs = to === null ? null : Date.parse(to)
|
||||
if ((fromMs !== null && !Number.isFinite(fromMs)) || (toMs !== null && !Number.isFinite(toMs))) {
|
||||
throw new Error('janela inválida: from/to precisam ser instantes ISO ou null.')
|
||||
}
|
||||
|
||||
const existing = new Set(existingSourceUrls)
|
||||
const seen = new Set<string>()
|
||||
const candidates: ContentPieceProfileWindowCandidate[] = []
|
||||
const existingShortcodes: string[] = []
|
||||
let outsideWindow = 0
|
||||
let malformed = 0
|
||||
let feedDuplicates = 0
|
||||
|
||||
for (const post of posts) {
|
||||
const timestamp = Date.parse(post.timestamp)
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
// An item without a usable timestamp cannot be placed in any window.
|
||||
malformed += 1
|
||||
continue
|
||||
}
|
||||
if ((fromMs !== null && timestamp < fromMs) || (toMs !== null && timestamp > toMs)) {
|
||||
outsideWindow += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const candidate = contentPieceProfileCandidateFromPost(post)
|
||||
const link = candidate ? parseContentPieceLink(candidate.url) : null
|
||||
if (!candidate || !link) {
|
||||
malformed += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const identityUrls = contentPiecePostIdentityUrls(link)
|
||||
const shortcode = link.origin === 'instagram' ? link.shortcode : link.videoId
|
||||
if (identityUrls.some((url) => existing.has(url))) {
|
||||
existingShortcodes.push(shortcode)
|
||||
continue
|
||||
}
|
||||
if (identityUrls.some((url) => seen.has(url))) {
|
||||
feedDuplicates += 1
|
||||
continue
|
||||
}
|
||||
identityUrls.forEach((url) => seen.add(url))
|
||||
candidates.push({
|
||||
url: candidate.url,
|
||||
shortcode,
|
||||
linkOnlyReason: candidate.linkOnlyReason,
|
||||
timestamp: post.timestamp,
|
||||
mediaType: post.mediaType,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
window: { from, to },
|
||||
found: candidates.length + existingShortcodes.length + feedDuplicates,
|
||||
existingCount: existingShortcodes.length,
|
||||
existingShortcodes,
|
||||
feedDuplicates,
|
||||
malformed,
|
||||
outsideWindow,
|
||||
candidates,
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -2,14 +2,20 @@ import 'server-only'
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { formatBahiaCivilDate } from '@/lib/campaignTime'
|
||||
import {
|
||||
contentPieceLinkTitle,
|
||||
contentPiecePostIdentityUrls,
|
||||
contentPieceProfileCandidateFromPost,
|
||||
parseContentPieceLink,
|
||||
type ContentPieceLink,
|
||||
type ContentPieceProfileCandidate,
|
||||
} from '@/lib/contentPiece'
|
||||
import {
|
||||
contentPieceProfileFeedIdentityUrls,
|
||||
contentPieceProfilePeriodBounds,
|
||||
contentPieceProfilePeriodError,
|
||||
contentPieceProfileWindowStartReached,
|
||||
planContentPieceProfileWindow,
|
||||
type ContentPieceProfileWindow,
|
||||
} from '@/lib/contentPieceProfileWindow'
|
||||
import {
|
||||
CONTENT_PIECE_LINK_INVALID_MESSAGE,
|
||||
CONTENT_PIECE_PROFILE_IMPORT_FEED_ERROR_MESSAGE,
|
||||
|
|
@ -39,9 +45,26 @@ import {
|
|||
/** The recency window of one import (12 media ≈ the profile's last days). */
|
||||
export const CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW = 12
|
||||
|
||||
/**
|
||||
* C235 — the scan ceiling of one period import: the official edge itself only
|
||||
* answers the 10K most recently created media, so this is the real limit of
|
||||
* the walk, not a product cap.
|
||||
*/
|
||||
export const CONTENT_PIECE_PROFILE_IMPORT_SCAN_LIMIT = 10_000
|
||||
|
||||
/** A hung Graph API must not hold the confirm dialog until the job reaper. */
|
||||
const CONTENT_PIECE_PROFILE_IMPORT_FEED_TIMEOUT_MS = 30_000
|
||||
|
||||
/** A period walk can cost many pages; the honest ceiling is still the edge. */
|
||||
const CONTENT_PIECE_PROFILE_IMPORT_PERIOD_FEED_TIMEOUT_MS = 120_000
|
||||
|
||||
/**
|
||||
* The catalogue identity lookup asks `sourceUrl in (...)`; a 10K-media window
|
||||
* spells up to 30K URLs, so the probe runs in bounded chunks instead of one
|
||||
* oversized statement.
|
||||
*/
|
||||
const CONTENT_PIECE_PROFILE_IMPORT_IDENTITY_LOOKUP_CHUNK = 2_000
|
||||
|
||||
type FeedLoader = typeof loadInstagramFeed
|
||||
type FetchLike = (input: string, requestInit?: RequestInit) => Promise<Response>
|
||||
|
||||
|
|
@ -52,6 +75,12 @@ export type ContentPieceProfileImportListing = {
|
|||
existingCount: number
|
||||
/** The novelties, in feed order (newest first). */
|
||||
candidates: ContentPieceProfileCandidate[]
|
||||
/**
|
||||
* True when the official API did not reach the start of the requested window
|
||||
* (edge limit, page ceiling or end of pagination): the receipt says the
|
||||
* window was not fully covered. Always false for the recency window.
|
||||
*/
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type ContentPieceProfileImportOutcome = 'created' | 'existing'
|
||||
|
|
@ -71,24 +100,31 @@ const cataloguedSourceUrls = async ({
|
|||
actor: CampaignUser
|
||||
identityUrls: readonly string[]
|
||||
}): Promise<Set<string>> => {
|
||||
if (identityUrls.length === 0) return new Set()
|
||||
const unique = [...new Set(identityUrls)]
|
||||
const catalogued = new Set<string>()
|
||||
|
||||
const found = await payload.find({
|
||||
collection: 'contentPiece',
|
||||
where: { sourceUrl: { in: [...new Set(identityUrls)] } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { sourceUrl: true },
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
for (
|
||||
let index = 0;
|
||||
index < unique.length;
|
||||
index += CONTENT_PIECE_PROFILE_IMPORT_IDENTITY_LOOKUP_CHUNK
|
||||
) {
|
||||
const chunk = unique.slice(index, index + CONTENT_PIECE_PROFILE_IMPORT_IDENTITY_LOOKUP_CHUNK)
|
||||
const found = await payload.find({
|
||||
collection: 'contentPiece',
|
||||
where: { sourceUrl: { in: chunk } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { sourceUrl: true },
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
for (const doc of found.docs) {
|
||||
if (typeof doc.sourceUrl === 'string' && doc.sourceUrl) catalogued.add(doc.sourceUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return new Set(
|
||||
found.docs
|
||||
.map((doc) => doc.sourceUrl)
|
||||
.filter((sourceUrl): sourceUrl is string => Boolean(sourceUrl)),
|
||||
)
|
||||
return catalogued
|
||||
}
|
||||
|
||||
/** True when the official credential is armed — what arms the import button. */
|
||||
|
|
@ -106,16 +142,28 @@ export const readContentPieceProfileImportAvailability = async (
|
|||
* unconfigured credential never reaches the API, and a feed failure becomes
|
||||
* the honest product message instead of a raw transport error. A refreshed
|
||||
* token is persisted best-effort, exactly like the link path.
|
||||
*
|
||||
* C235 — the recency window keeps the C230 one-page read; a period window
|
||||
* scans cursor pages with a date early-stop (the feed is newest-first, so the
|
||||
* first post older than the requested start ends the walk) up to the edge's
|
||||
* own 10K-media limit, and denounces the bounds actually applied.
|
||||
*/
|
||||
const loadConfiguredInstagramFeed = async ({
|
||||
payload,
|
||||
loadFeed,
|
||||
fetchImpl,
|
||||
window,
|
||||
now,
|
||||
}: {
|
||||
payload: Payload
|
||||
loadFeed: FeedLoader
|
||||
fetchImpl: FetchLike
|
||||
}): Promise<Awaited<ReturnType<FeedLoader>>> => {
|
||||
window: ContentPieceProfileWindow
|
||||
now: Date
|
||||
}): Promise<{
|
||||
feed: Awaited<ReturnType<FeedLoader>>
|
||||
bounds: { fromIso: string; toIso: string | null } | null
|
||||
}> => {
|
||||
// Intentional admin bypass: the global's read access is admin-only, and the
|
||||
// listing is already behind the communication gate; the token stays inside
|
||||
// this call and never reaches the wire or a log.
|
||||
|
|
@ -124,14 +172,35 @@ const loadConfiguredInstagramFeed = async ({
|
|||
throw new Error(CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
|
||||
if (window.mode === 'period') {
|
||||
const error = contentPieceProfilePeriodError({
|
||||
since: window.since,
|
||||
until: window.until ?? null,
|
||||
today: formatBahiaCivilDate(now),
|
||||
})
|
||||
if (error) throw new Error(error)
|
||||
}
|
||||
const bounds =
|
||||
window.mode === 'period'
|
||||
? contentPieceProfilePeriodBounds({ since: window.since, until: window.until ?? null })
|
||||
: null
|
||||
const fromMs = bounds ? Date.parse(bounds.fromIso) : null
|
||||
|
||||
let feed: Awaited<ReturnType<FeedLoader>>
|
||||
try {
|
||||
feed = await loadFeed({
|
||||
accessToken: settings.instagramAccessToken as string,
|
||||
userId: settings.instagramUserId as string,
|
||||
maxResults: CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW,
|
||||
maxResults: bounds
|
||||
? CONTENT_PIECE_PROFILE_IMPORT_SCAN_LIMIT
|
||||
: CONTENT_PIECE_PROFILE_IMPORT_INSTAGRAM_WINDOW,
|
||||
...(fromMs !== null ? { shouldStopAt: (post) => Date.parse(post.timestamp) < fromMs } : {}),
|
||||
fetchImpl,
|
||||
signal: AbortSignal.timeout(CONTENT_PIECE_PROFILE_IMPORT_FEED_TIMEOUT_MS),
|
||||
signal: AbortSignal.timeout(
|
||||
bounds
|
||||
? CONTENT_PIECE_PROFILE_IMPORT_PERIOD_FEED_TIMEOUT_MS
|
||||
: CONTENT_PIECE_PROFILE_IMPORT_FEED_TIMEOUT_MS,
|
||||
),
|
||||
})
|
||||
} catch {
|
||||
// Token expired, API down: the operator reads the retry copy, never a
|
||||
|
|
@ -142,52 +211,64 @@ const loadConfiguredInstagramFeed = async ({
|
|||
if (feed.refreshedAccessToken) {
|
||||
await persistInstagramAccessToken(payload, feed.refreshedAccessToken).catch(() => undefined)
|
||||
}
|
||||
return feed
|
||||
return { feed, bounds }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the window's novelties, already deduplicated by post identity: `found`
|
||||
* is what the API answered with (parseable), `existingCount` what the Central
|
||||
* already had and `candidates` what the confirmation will create, newest
|
||||
* first.
|
||||
* is what the API answered with inside the window (parseable), `existingCount`
|
||||
* what the Central already had, `candidates` what the confirmation will
|
||||
* create, newest first, and `truncated` when the official API did not reach
|
||||
* the requested start.
|
||||
*
|
||||
* The recency mode is the C230 behavior untouched (12 media, one page); the
|
||||
* period mode is the C235 window — the planner is the same owner for both.
|
||||
*/
|
||||
export const listContentPieceProfileImportCandidates = async ({
|
||||
payload,
|
||||
actor,
|
||||
window = { mode: 'recent' },
|
||||
loadFeed = loadInstagramFeed,
|
||||
fetchImpl = fetch,
|
||||
now = new Date(),
|
||||
}: {
|
||||
payload: Payload
|
||||
actor: CampaignUser
|
||||
window?: ContentPieceProfileWindow
|
||||
loadFeed?: FeedLoader
|
||||
fetchImpl?: FetchLike
|
||||
now?: Date
|
||||
}): Promise<ContentPieceProfileImportListing> => {
|
||||
const feed = await loadConfiguredInstagramFeed({ payload, loadFeed, fetchImpl })
|
||||
|
||||
const parsed: { link: ContentPieceLink; candidate: ContentPieceProfileCandidate }[] = []
|
||||
for (const post of feed.posts) {
|
||||
const candidate = contentPieceProfileCandidateFromPost(post)
|
||||
if (!candidate) continue
|
||||
const link = parseContentPieceLink(candidate.url)
|
||||
if (!link) continue
|
||||
parsed.push({ link, candidate })
|
||||
}
|
||||
const { feed, bounds } = await loadConfiguredInstagramFeed({
|
||||
payload,
|
||||
loadFeed,
|
||||
fetchImpl,
|
||||
window,
|
||||
now,
|
||||
})
|
||||
|
||||
const catalogued = await cataloguedSourceUrls({
|
||||
payload,
|
||||
actor,
|
||||
identityUrls: parsed.flatMap(({ link }) => contentPiecePostIdentityUrls(link)),
|
||||
identityUrls: contentPieceProfileFeedIdentityUrls(feed.posts),
|
||||
})
|
||||
const plan = planContentPieceProfileWindow({
|
||||
posts: feed.posts,
|
||||
existingSourceUrls: [...catalogued],
|
||||
from: bounds?.fromIso ?? null,
|
||||
to: bounds?.toIso ?? null,
|
||||
})
|
||||
const candidates = parsed
|
||||
.filter(({ link }) =>
|
||||
contentPiecePostIdentityUrls(link).every((identityUrl) => !catalogued.has(identityUrl)),
|
||||
)
|
||||
.map(({ candidate }) => candidate)
|
||||
|
||||
return {
|
||||
found: parsed.length,
|
||||
existingCount: parsed.length - candidates.length,
|
||||
candidates,
|
||||
found: plan.found,
|
||||
existingCount: plan.existingCount,
|
||||
candidates: plan.candidates.map(({ url, linkOnlyReason }) => ({ url, linkOnlyReason })),
|
||||
truncated:
|
||||
feed.posts.length > 0 &&
|
||||
!contentPieceProfileWindowStartReached({
|
||||
posts: feed.posts,
|
||||
fromIso: bounds?.fromIso ?? null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
223
tests/unit/contentPieceProfileWindow.unit.spec.ts
Normal file
223
tests/unit/contentPieceProfileWindow.unit.spec.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
contentPieceProfileFeedIdentityUrls,
|
||||
contentPieceProfilePeriodBounds,
|
||||
contentPieceProfilePeriodError,
|
||||
contentPieceProfileWindowLabel,
|
||||
contentPieceProfileWindowStartReached,
|
||||
planContentPieceProfileWindow,
|
||||
} from '@/lib/contentPieceProfileWindow'
|
||||
|
||||
// C235 — the window owner: the civil-date bounds of the operator's period, the
|
||||
// recency filter over the feed timestamps, the identity dedupe (Central and
|
||||
// feed itself) and the honest coverage check. Pure: no Payload, no network.
|
||||
|
||||
const post = (
|
||||
shortcode: string,
|
||||
{
|
||||
mediaType = 'REEL',
|
||||
mediaUrl = `https://cdn.example/${shortcode}.mp4`,
|
||||
timestamp = '2026-09-20T12:00:00.000Z',
|
||||
permalink,
|
||||
}: {
|
||||
mediaType?: string
|
||||
mediaUrl?: string | null
|
||||
timestamp?: string
|
||||
permalink?: string
|
||||
} = {},
|
||||
) => ({
|
||||
id: shortcode,
|
||||
permalink: permalink ?? `https://www.instagram.com/reel/${shortcode}/`,
|
||||
mediaType,
|
||||
mediaUrl,
|
||||
timestamp,
|
||||
})
|
||||
|
||||
describe('planContentPieceProfileWindow', () => {
|
||||
const from = '2026-08-01T03:00:00.000Z'
|
||||
const to = '2026-09-21T03:00:00.000Z'
|
||||
|
||||
it('keeps the window novelties, newest first, and classifies each link-only kind', () => {
|
||||
const plan = planContentPieceProfileWindow({
|
||||
posts: [
|
||||
post('NOVO'),
|
||||
post('CARROSSEL', { mediaType: 'CAROUSEL_ALBUM', mediaUrl: null }),
|
||||
post('PROTEGIDO', { mediaUrl: null }),
|
||||
],
|
||||
from,
|
||||
to,
|
||||
})
|
||||
|
||||
expect(plan.found).toBe(3)
|
||||
expect(plan.existingCount).toBe(0)
|
||||
expect(plan.outsideWindow).toBe(0)
|
||||
expect(plan.malformed).toBe(0)
|
||||
expect(plan.window).toEqual({ from, to })
|
||||
expect(plan.candidates.map((candidate) => candidate.shortcode)).toEqual([
|
||||
'NOVO',
|
||||
'CARROSSEL',
|
||||
'PROTEGIDO',
|
||||
])
|
||||
expect(plan.candidates.map((candidate) => candidate.linkOnlyReason)).toEqual([
|
||||
null,
|
||||
'carrossel',
|
||||
'indisponivel',
|
||||
])
|
||||
})
|
||||
|
||||
it('drops what falls outside the window, the malformed and the feed twins', () => {
|
||||
const plan = planContentPieceProfileWindow({
|
||||
posts: [
|
||||
post('JA', { permalink: 'https://www.instagram.com/reel/JA/' }),
|
||||
post('ANTES', { timestamp: '2026-07-31T23:59:59.000Z' }),
|
||||
post('DEPOIS', { timestamp: '2026-09-22T00:00:00.000Z' }),
|
||||
post('SEMID', { permalink: 'https://example.com/no-id/' }),
|
||||
post('REPETIDA'),
|
||||
post('REPETIDA', { permalink: 'https://www.instagram.com/p/REPETIDA/' }),
|
||||
],
|
||||
existingSourceUrls: ['https://www.instagram.com/p/JA/'],
|
||||
from,
|
||||
to,
|
||||
})
|
||||
|
||||
expect(plan.found).toBe(3)
|
||||
expect(plan.existingCount).toBe(1)
|
||||
expect(plan.existingShortcodes).toEqual(['JA'])
|
||||
expect(plan.outsideWindow).toBe(2)
|
||||
expect(plan.malformed).toBe(1)
|
||||
expect(plan.feedDuplicates).toBe(1)
|
||||
expect(plan.candidates.map((candidate) => candidate.shortcode)).toEqual(['REPETIDA'])
|
||||
})
|
||||
|
||||
it('treats an unbounded window as the whole feed (the recency mode)', () => {
|
||||
const plan = planContentPieceProfileWindow({
|
||||
posts: [
|
||||
post('ANTIGA', { timestamp: '2020-01-01T00:00:00.000Z' }),
|
||||
post('NOVA', { timestamp: '2026-09-20T12:00:00.000Z' }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(plan.window).toEqual({ from: null, to: null })
|
||||
expect(plan.found).toBe(2)
|
||||
expect(plan.outsideWindow).toBe(0)
|
||||
expect(plan.candidates.map((candidate) => candidate.shortcode)).toEqual(['ANTIGA', 'NOVA'])
|
||||
})
|
||||
|
||||
it('refuses an invalid bound', () => {
|
||||
expect(() => planContentPieceProfileWindow({ posts: [], from: 'ontem' })).toThrow(/janela/)
|
||||
expect(() => planContentPieceProfileWindow({ posts: [], to: 'amanhã' })).toThrow(/janela/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentPieceProfilePeriodError', () => {
|
||||
const today = '2026-09-29'
|
||||
|
||||
it('accepts a real, ordered, non-future period', () => {
|
||||
expect(contentPieceProfilePeriodError({ since: '2026-08-01', today })).toBeNull()
|
||||
expect(
|
||||
contentPieceProfilePeriodError({ since: '2026-08-01', until: '2026-08-31', today }),
|
||||
).toBeNull()
|
||||
expect(contentPieceProfilePeriodError({ since: today, today })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a malformed or impossible date', () => {
|
||||
expect(contentPieceProfilePeriodError({ since: '01/08/2026', today })).toBe(
|
||||
'Informe uma data inicial válida.',
|
||||
)
|
||||
expect(contentPieceProfilePeriodError({ since: '2026-02-30', today })).toBe(
|
||||
'Informe uma data inicial válida.',
|
||||
)
|
||||
expect(
|
||||
contentPieceProfilePeriodError({ since: '2026-08-01', until: '2026-02-30', today }),
|
||||
).toBe('Informe uma data final válida.')
|
||||
})
|
||||
|
||||
it('rejects an inverted or future period', () => {
|
||||
expect(
|
||||
contentPieceProfilePeriodError({ since: '2026-08-31', until: '2026-08-01', today }),
|
||||
).toBe('A data final não pode ser anterior à data inicial.')
|
||||
expect(contentPieceProfilePeriodError({ since: '2026-10-01', today })).toBe(
|
||||
'A data inicial não pode estar no futuro.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentPieceProfilePeriodBounds', () => {
|
||||
it('resolves the start of the since day and the exclusive end of the until day in Bahia', () => {
|
||||
expect(contentPieceProfilePeriodBounds({ since: '2026-08-01' })).toEqual({
|
||||
fromIso: '2026-08-01T03:00:00.000Z',
|
||||
toIso: null,
|
||||
})
|
||||
expect(contentPieceProfilePeriodBounds({ since: '2026-08-01', until: '2026-08-31' })).toEqual({
|
||||
fromIso: '2026-08-01T03:00:00.000Z',
|
||||
toIso: '2026-09-01T03:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses an impossible date defensively', () => {
|
||||
expect(() => contentPieceProfilePeriodBounds({ since: '2026-13-01' })).toThrow(
|
||||
'Informe uma data inicial válida.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentPieceProfileWindowStartReached', () => {
|
||||
const fromIso = '2026-08-01T03:00:00.000Z'
|
||||
|
||||
it('is reached when the walk got a post older than the window start', () => {
|
||||
expect(
|
||||
contentPieceProfileWindowStartReached({
|
||||
posts: [post('NOVA'), post('ANTES', { timestamp: '2026-07-31T23:59:59.000Z' })],
|
||||
fromIso,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('is not reached when every post is still inside the window', () => {
|
||||
expect(contentPieceProfileWindowStartReached({ posts: [post('NOVA')], fromIso })).toBe(false)
|
||||
expect(contentPieceProfileWindowStartReached({ posts: [], fromIso })).toBe(false)
|
||||
})
|
||||
|
||||
it('is reached when there is no bound to reach', () => {
|
||||
expect(contentPieceProfileWindowStartReached({ posts: [], fromIso: null })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentPieceProfileWindowLabel', () => {
|
||||
it('labels the recency and the civil period', () => {
|
||||
expect(contentPieceProfileWindowLabel({ mode: 'recent' })).toBe('Recentes')
|
||||
expect(contentPieceProfileWindowLabel({ mode: 'period', since: '2026-08-01' })).toBe(
|
||||
'01/08/2026 → hoje',
|
||||
)
|
||||
expect(
|
||||
contentPieceProfileWindowLabel({
|
||||
mode: 'period',
|
||||
since: '2026-08-01',
|
||||
until: '2026-08-31',
|
||||
}),
|
||||
).toBe('01/08/2026 → 31/08/2026')
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentPieceProfileFeedIdentityUrls', () => {
|
||||
it('spells every canonical kind of each parseable post once', () => {
|
||||
expect(
|
||||
contentPieceProfileFeedIdentityUrls([
|
||||
post('A'),
|
||||
post('B', { permalink: 'https://www.instagram.com/p/B/' }),
|
||||
post('A', { permalink: 'https://www.instagram.com/p/A/' }),
|
||||
post('X', { permalink: 'https://example.com/x/' }),
|
||||
]),
|
||||
).toEqual([
|
||||
'https://www.instagram.com/p/A/',
|
||||
'https://www.instagram.com/reel/A/',
|
||||
'https://www.instagram.com/tv/A/',
|
||||
'https://www.instagram.com/p/B/',
|
||||
'https://www.instagram.com/reel/B/',
|
||||
'https://www.instagram.com/tv/B/',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue