feat(C211): Central de Conteúdos interna — envio em lote, link, catalogação e publicação

Envio em lote (1 request raw-body por arquivo, fan-out ≤2, até 50 arquivos) e
adicionar por link (Instagram/YouTube; extração só pela Graph API da conta
própria, senão peça-link) em /campanha/comunicacao/conteudos. Cada peça tem
estado próprio (Processando/Pronto/Falhou + Reprocessar), transcrição/texto e
catalogação automática revisável; publicar/despublicar preserva arquivo, slug
e publishedAt.

- collections contentPiece + contentMedia (upload privado no S3) e migration
  20260923_032714_add_content_piece (slug único gerado no 1º publish,
  sourceUrl única, searchText com índice trigram)
- pipeline reusa C199 (job after() + reaper + retry condicional, transcrição
  DeepInfra, ffmpeg/chunk), o classificador de facetas do acervo e o
  institutionCatalog; curatedFields impede o job de sobrescrever curadoria
- gate único canReadCommunicationCatalog (advisor/leader negados); delete
  só pelo admin; sem Consent novo
- UI portada do design aprovado (lista desktop + cards mobile, diálogo de
  lote com progresso por arquivo, diálogo de link, ficha editável, kill
  switch); nav/chrome/column-picker/manifest atualizados
- instagramFeed expõe mediaUrl; revalidateTag ganha wrapper seguro fora de
  request (fixtures/scripts)
- testes: 2 unit specs, 1 int spec (16 casos) e 7 e2e na vertical
- follow-up de engenharia: #1267 (docs/plans/escala-dry-pos-c211.md)

Closes #1254
This commit is contained in:
Francisco Solla 2026-09-23 02:06:08 -03:00
parent 6d721ec709
commit 71a27aa4ec
60 changed files with 25671 additions and 14 deletions

1
.gitignore vendored
View file

@ -59,6 +59,7 @@ next-env.d.ts
/media
/reelMedia
/recordingMedia
/contentMedia
# TSE open-data zip cache (pnpm db:seed:tse)
/data/tse/

View file

@ -0,0 +1 @@
**Central de Conteúdos interna — envio em lote, link, catalogação e publicação (C211, 2026-09-22):** a assessoria de comunicação ganhou `/campanha/comunicacao/conteudos` (sub-item "Conteúdos" na nav da vertical): envia N arquivos de uma vez (1 request raw-body por arquivo, fan-out ≤2 no cliente, até 50 por lote, 4 GiB cada) ou cola um link do Instagram/YouTube, e cada peça sai com estado próprio ("Processando" | "Pronto" | "Falhou" + "Reprocessar"), transcrição/texto e catalogação automática revisável (título, descrição, tipo, temas, cidade, região, instituição, data, duração). Duas collections novas — `contentPiece` (enums pt-BR, `slug` gerado no 1º "Publicar" e imutável, `sourceUrl` única, `searchText` com índice trigram) e `contentMedia` (upload privado, registrado no S3) — com migration `20260923_032714_add_content_piece`; gate único `canReadCommunicationCatalog` (advisor/leader negados; delete só pelo admin). O pipeline reusa o que já tinha dono: job `after()` + reaper preguiçoso + retry condicional do C199, `deepInfraTranscribeSegments` e os helpers de ffmpeg/chunk, o classificador de facetas do acervo e o `institutionCatalog`; a extração de link é só pela Graph API da conta própria (casa o permalink canônico; YouTube/terceiro = peça-link, sem download de terceiro). `curatedFields` impede o job de sobrescrever edição humana; o kill switch preserva arquivo, slug e `publishedAt`, e um `afterChange` na collection busta o tag `contentPieces` (a costura de cache com o S27). `instagramFeed` passou a expor `mediaUrl` (campo que o `MEDIA_FIELDS` já pedia). UI portada classe-a-classe do design aprovado (`central-conteudos-ingestao-ui-design.html`): lista desktop + cards mobile, diálogo de lote com progresso por arquivo, diálogo de link com o aviso "Sem scraping", ficha editável e Publicar/Despublicar. Testes: 2 unit specs novos (vocabulário/link/slug/lista + catalogação com deps injetadas), 1 int spec (access, upload, job, falha/retry, reaper, texto, publish/slug, curadoria, link/duplicado, anexo, lista) e 7 e2e na vertical de comunicação. Sem Consent novo (não há PII de eleitor) e sem invasão do S27/C212/C213. Endurecimento de dono compartilhado: `revalidateTag` passou a ser chamado por um wrapper que engole só o invariante "static generation store missing" (write de Local API fora de request — fixtures e2e/scripts/seeds não têm cache para bustar), preservando o erro real dentro de request. Follow-up de engenharia registrado em #1267 (`docs/plans/escala-dry-pos-c211.md`).

View file

@ -0,0 +1,236 @@
# Impl: C211 — Central de Conteúdos — enviar, transcrever e catalogar as peças de campanha (interno)
Status: em execução
Atualizado em: 2026-09-23
Issue: #1254
Intenção: docs/plans/central-conteudos-ingestao.md
Design UI (gate): docs/plans/central-conteudos-ingestao-ui-design.html
Appetite restante: herdado (~2–3 dias eng) — collection nova + pipeline + port classe-a-classe da UI; scraping, editor e fila genérica cortados.
Modo: autônomo (`--auto`) — impl plan nasce aprovado pelo agente.
> **Schema confirmado pelo humano no gate (2026-09-23).** As duas collections (`contentPiece`, `contentMedia`) e a migration `20260923_032714_add_content_piece` foram aprovadas (D1 upload novo; D2 sem `contentPieceSegment`; D7 slug no primeiro publish; D4 `sourceUrl` única). O que mudou na execução está em "Desvios registrados na execução", abaixo.
## Leitura da intenção
- **Outcome:** a assessoria sobe um lote de peças (ou cola um link do Instagram/YouTube) em `/campanha/comunicacao/conteudos` e cada peça sai com estado próprio ("Processando" | "Pronto" | "Falhou"), transcrição/texto e catalogação automática (título, descrição, tipo, temas, cidade, região, instituição, data, duração) — tudo editável —, e publica/despublica com um gesto; `communicator`/`coordinator`/`candidate` operam, `advisor`/`leader` negados (fail-closed).
- **O que NÃO negociar:**
- Gate: `canReadCommunicationCatalog` (`src/lib/campaignRoles.ts:27-28`) — não criar gate gêmeo; advisor/leader negados na página, nas actions, na collection e na rota de mídia.
- **Sem PII de eleitor e sem `Consent` novo** — a peça é material de campanha; `createdBy` é relação com `campaignUser`, não cadastro de pessoa. Nada aqui se parece com opt-in LGPD; se algo exigir Consent, **parar e registrar** (não é o caso).
- Extração só por caminho oficial; **mídia de terceiro nunca é baixada**; sem scraping (scraping é investigação do C212, nunca mecanismo).
- "Rascunho" é o padrão; despublicar tira da Central na hora **sem apagar arquivo**; "Falhou" preserva o arquivo e permite "Reprocessar".
- **URL interna fixada:** `/campanha/comunicacao/conteudos`. **URL pública `/conteudos/<slug>`, página pública, filtros públicos e serving público da mídia são do S27** — este item não os cria.
- Sem segunda cópia de arquivo que já tem dono (jingle/artigo/card) e sem duplicar peça que já tem dono no site.
- Identificadores em inglês; literais visíveis em pt-BR; migrations existentes nunca editadas.
- **O que reavaliar (hipóteses do plano de intenção):**
- "Adicionar por link extrai a mídia quando a plataforma oferecer caminho oficial": a Graph API **não tem fetch por link** — o caminho é listar `GET /{userId}/media` e casar o `permalink` com o link colado (`src/utilities/socialFeed/instagramFeed.ts:204-256`). Para o YouTube **não existe** caminho oficial de download de arquivo (a Data API só dá metadados) → peça-link.
- "Lote" não tem precedente no app: C199 é 1 request/1 job por arquivo (`src/utilities/recordings/recordingUpload.ts:68-144`); o único precedente de lote é import CSV com token HMAC (`src/collections/SupporterImportBatch.ts`), que não se aplica a arquivos.
- "Peça nasce Rascunho" (assumido no produto) permanece; publicar é gesto explícito.
## Abordagem recomendada
```mermaid
flowchart LR
U["/campanha/comunicacao/conteudos\n(lista: Peça · Processamento · Publicação · Próxima ação)"] --> UP["Enviar peças\nN arquivos · 1 request/arquivo (raw body)\nfan-out no cliente, concorrência ≤ 2"]
U --> LK["Adicionar por link (IG/YT)\nnormaliza URL"]
UP --> RW["contentPiece: processando\ncontentMedia privada (S3)"]
LK --> EX{"Caminho oficial?"}
EX -- "IG da conta própria\n(Graph API: listar mídia e casar permalink)" --> RW
EX -- "YouTube / terceiro" --> PL["peça-link: sem arquivo\n'pronto' + Anexar arquivo original"]
RW --> JOB["after() → runContentPieceJob\n(extraindo → transcrevendo → catalogando → salvando)"]
JOB --> CAT["classifySpeech (gazetteer+LLM)\n+ sugestão de título/descrição\n→ temas, cidade/região, instituição"]
CAT --> RDY["processando → pronto"]
PL --> RDY
RDY --> CUR["Ficha editável\n(curatedFields protege edição humana)"]
CUR --> PUB["Publicar/Despublicar\nstatus=publicado|rascunho · slug no 1º publish\nrevalidateTag(contentPieces)"]
PUB --> S27["S27 lê publicado + slug + media\n(o serving público é do S27)"]
JOB -. falha .-> FAIL["falhou (arquivo preservado)\nReprocessar → mesmo job, mesma linha"]
FAIL --> JOB
```
**Opções consideradas (abordagem geral):** A) collection própria da peça + upload privado novo, pipeline reusando os helpers do C199/puros, lote por fan-out no cliente, extração só pela Graph API da conta própria e catalogação gazetteer+LLM; B) modelar a peça sobre `recording`/`reel` existentes; C) modelar a peça sobre `media` (pública) com upload multipart.
**Recomendação:** **A** — é a única que mantém o dono de cada mecanismo (C199/C193 intocados), nasce fail-closed para rascunho e cabe no appetite reusando o que já é profundo (rota raw-body, job `after()`+reaper+retry condicional, mídia privada, taxonomias, shells de lista).
**Rejeitadas:** **B** porque acopla o ciclo de vida da peça ao da gravação/reel (delete/cascade, acesso e vocabulário alheios) e cria duas fontes de verdade para o mesmo arquivo; **C** porque `media` é leitura anônima por contrato (`src/collections/Media.ts:16-21`) — um rascunho vazaria por `/api/media/file/<filename>` — e multipart/FormData já foi descartado no C199 por buffer de horas de vídeo.
### D1 — Dono e serving da mídia (collection de upload)
**Opções:** A) collection de upload **nova e privada** `contentMedia` + rota interna autenticada sob `/campanha` + costura explícita para o S27 servir o publicado; B) reusar `media` (pública, leitura anônima); C) reusar `recordingMedia` (C199) ou `reelMedia` (C193).
**Recomendação:** **A** — `contentMedia` registrada em `s3Storage.collections` (`src/payload.config.ts:176-195`) e servida por `/campanha/comunicacao/conteudos/[id]/arquivo` com `buildPrivateMediaResponse` (`src/utilities/privateMedia/privateMediaResponse.ts:151-186`), o mesmo dono de I/O privado do C193/C199. **Costura com o S27 (contrato, não implementação):** o S27 lê `contentPiece.status === 'publicado'` + `slug` + `media` e serve o arquivo numa rota pública própria (gate `canServeContentMedia`/`contentPieceIsPublic`, puras em `src/lib/contentPiece.ts`), usando o mesmo helper — **sem segunda cópia** e **sem** `/api/media/file` (esse proxy só serve `media`). Sem `unstable_cache` neste item.
**Rejeitadas:** **B** porque `media.read: () => true` (`src/collections/Media.ts:16-21`) tornaria o arquivo de um rascunho publicamente alcançável por URL — mata o kill switch; **C** porque acopla donos: o acesso (`canReadRecording`), o delete em cascata e o vocabulário de C199/C193 não são os da peça, e a primeira mudança de um dono passaria a mexer no outro.
### D2 — Modelo da peça (collections, enums, campos)
**Opções:** A) **uma** collection `contentPiece` + upload `contentMedia`, **sem** collection de segmentos; transcrição guardada como texto na própria peça; enums com **valores pt-BR sem acento** e labels acentuados (precedente `activityStatuses`, `src/lib/schemas/activity.ts:52-59`); B) `contentPiece` + `contentPieceSegment` (transcrição com timestamps por segmento); C) duas collections (peça-arquivo × peça-link) + slug gerado no create.
**Recomendação:** **A** — o design aprovado fixa uma ficha com **uma** área "Transcrição" (não um player com trechos) e o S28 busca por texto; segmentos são custo de schema/cascade/índice sem consumidor hoje. Campos da peça (admin group `Comunicação`, labels "Peça"/"Peças", `useAsTitle: 'title'`):
- `title` — text, required, `CONTENT_PIECE_TITLE_MAX_LENGTH = 200`.
- `slug` — text, **unique + index, nullable**, readOnly; canônico e imutável depois do primeiro publish (D7).
- `type` — select required + index: `video` ("Vídeo") | `foto` ("Foto") | `texto` ("Texto") | `audio` ("Áudio") | `card` ("Card").
- `description` — textarea.
- `topics` — select hasMany + index, opções de `SPEECH_TOPICS` (`src/lib/speechFacets.ts:14-33`), como `Speech.topics` (`src/collections/Speech.ts:271-277`).
- `municipality` — relationship → `municipality` (opcional) + index; **uma** cidade por peça (o design mostra "Cidade · região").
- `region` — text readOnly; derivado de `territoryForCity(entry.city)` (`src/lib/bahiaTerritories.ts:636-650`).
- `institution` — text; nome canônico quando o catálogo casar (`institutionCatalog`/`institutionSpellings`, `src/lib/institutionCatalog.ts:112-126`), senão texto livre.
- `pieceDate` — date + index ("Data").
- `durationSeconds` — number readOnly (só vídeo/áudio, medido pelo provedor).
- `transcript` — textarea (transcrição ou texto extraído).
- `media` — upload → `contentMedia`, readOnly (dono do arquivo é o pipeline de upload/anexo).
- `sourceUrl` — text, **unique + index, nullable**, readOnly; URL canônica normalizada (D4).
- `origin` — select required + index, default `arquivo`: `arquivo` ("Arquivo enviado") | `instagram` | `youtube`.
- `status` — select required + index, default `rascunho`: `rascunho` ("Rascunho") | `publicado` ("Publicado").
- `publishedAt` — date readOnly; carimbado pelo hook na transição para `publicado` (precedente `stampReelPublishedAt`, `src/collections/Reel.ts:51-59`).
- `processingStatus` — select required + index, default `pronto`: `processando` ("Processando") | `pronto` ("Pronto") | `falhou` ("Falhou").
- `step` — select readOnly: `extraindo` | `transcrevendo` | `catalogando` | `salvando` (vocabulário próprio; **não** reusa `RECORDING_STEPS`, que é do C199 e não tem `catalogando`).
- `error` — textarea readOnly (causa crua; a UI mostra copy honesta mapeada).
- `searchText` — textarea readOnly; `normalizeForSearch` (`src/lib/speechSearch.ts:9`) de título+descrição+transcrição+labels de temas+cidade+região+instituição — é o que o placeholder "Buscar por título, tema ou cidade…" promete.
- `curatedFields` — select hasMany readOnly, valores = os campos de catalogação (D6).
- `createdBy` — `systemStampedActorField` (`src/utilities/campaignAuditFields.ts`).
Índices: únicos de `slug` e `sourceUrl`; btree em `type`, `status`, `processingStatus`, `municipality`, `piece_date`, `topics`; **GIN trigram à mão** em `search_text` (mesmo padrão de `src/migrations/20260919_003440_add_recording.ts:83-84`). `contentMedia`: slug `contentMedia`, labels "Mídia de peça"/"Mídias de peças", `alt` required, `upload: true`, access = predicados novos.
**Migration:** `pnpm migrate:create add_content_piece` → `20260922_<HHMMSS>_add_content_piece` (as duas tabelas + enums + índices no mesmo arquivo; o índice trigram é acrescentado à mão no arquivo gerado, com `IF NOT EXISTS`). `pnpm migrate` local + `pnpm generate:types`; **nenhuma migration existente é editada**.
**Rejeitadas:** **B** porque o design não pede timestamp e um `contentPieceSegment` traria cascade, índice e loader sem consumidor (gatilho de revisitação: se o S27 pedir player com trechos ou o S28 pedir embeddings por trecho, é item próprio); **C** porque duplicaria a máquina de link/arquivo e criaria duas fontes de verdade para "peça".
### D3 — Pipeline de job e lote
**Opções:** A) **1 request por arquivo** na rota raw-body (padrão C199, `src/utilities/recordings/recordingUpload.ts:68-144`; rota `.../gravacoes/enviar/route.ts:56-121`), com **fan-out no cliente** (concorrência ≤ 2, progresso por arquivo via `upload.onprogress`), **sem entidade de lote**; job por peça via `after()` (`recordingScheduler.ts:15-19`), reaper preguiçoso de 60 min e retry condicional (`actions/recording.ts:67-96,102-143`); B) entidade de lote + token HMAC (precedente `supporterImportBatch`/`supporterImportToken`); C) multipart único com todos os arquivos.
**Recomendação:** **A** — é o mecanismo já provado no C199, dá progresso e falha isolada por arquivo (o design cenas 02/05 mostram exatamente isso) e evita estado de lote para limpar. Política do lote: `CONTENT_PIECE_BATCH_MAX_FILES = 50` (o picker recusa acima com copy clara — "limite generoso com aviso", recomendação do produto), `CONTENT_PIECE_MAX_BYTES = 4 GiB` por arquivo (streaming com teto, nunca buffer), tipo derivado do MIME/arquivo no **servidor** (`contentPieceTypeFromMime`, fail-closed), `video|audio` → `processando` + job; `texto` → `processando` + job (extrai o texto do arquivo); `foto|card` → `pronto` direto (o aceite não pede transcrição). Jobs concorrentes: sem semáforo no servidor (mesma política do C199; o fan-out ≤ 2 do cliente espaça as subidas) — **gatilho de revisitação:** se lotes > ~20 arquivos saturarem o box, um worker limitado vira item próprio (fila genérica é rabbit hole cortado pela intenção).
**Rejeitadas:** **B** porque o lote é só um agrupamento visual do cliente — uma collection de lote adiciona estado órfão, limpeza e reaper sem mudar o outcome (o token HMAC do CSV existe para _adiar_ um payload validado, não é o caso); **C** porque um request com N arquivos bufferiza/multiplexa mídia de horas e perde progresso e isolamento por arquivo (mesma razão pela qual o C199 descartou FormData).
### D4 — Adicionar por link e extração
**Opções:** A) extração **só** pela Graph API da conta própria (token/ID no global admin-only `social-feed-settings`, `src/globals/SocialFeedSettings.ts:63-66,137-153`): listar `/{userId}/media` com `permalink,media_url,media_type,timestamp,caption` e **casar o permalink** com o link colado; achou → baixa o arquivo (caminho oficial) e enfileira o **mesmo** pipeline; não achou/erro/sem token → peça-link (`origem` do host, sem arquivo, `pronto`) com "Anexar arquivo original"; YouTube e terceiros **sempre** peça-link (a Data API não entrega arquivo); **sem oEmbed**; B) oEmbed (IG/YT) para enriquecer metadados/thumbnail; C) só link para todos, sem extração.
**Recomendação:** **A** — cumpre a decisão do gate ("mídia extraída e catalogada quando houver caminho oficial") sem scraping e sem baixar mídia de terceiro; a normalização da URL (hosts IG/YT, path, remoção de `igsh`/`utm_*`, forma canônica) é pura e testável, com precedente de parse de host do YouTube em `src/lib/speechVod.ts:138`. **Dedup:** `sourceUrl` normalizada com **unique index** (nullable) + probe na action antes de criar; violação de corrida mapeada para a mesma mensagem segura ("Esta peça já está na Central.") — sem duas peças para o mesmo link. Título inicial identificável (`Instagram · <shortcode>` / `YouTube · <videoId>`), substituído pela catalogação (D5) enquanto não curado.
**Rejeitadas:** **B** porque oEmbed não entrega arquivo (só embed/thumbnail) e o C212 é quem vai recomendar o mecanismo de enriquecimento — implementar agora seria decidir no lugar dele (gatilho: achados do C212); **C** porque contraria a decisão registrada no gate.
### D5 — Shape da catalogação automática
**Opções:** A) gazetteer puro (`classifySpeechByGazetteer`, `src/lib/speechGazetteer.ts:449-476`) + refinamento validado reusando `classifySpeech` (`src/utilities/speech/speechClassifier.ts:76-136`) + **nova** sugestão de título/descrição com prompt de peça (`suggestContentPieceMetadata`, mesmo contrato never-throws/fallback determinístico de `speechCutMetadata.ts:68-114`); B) generalizar `suggestSpeechCutMetadata` para a peça; C) só gazetteer, sem LLM.
**Recomendação:** **A** — reusa a taxonomia e o refinamento que já existem (temas/cidade vêm do gazetteer + LLM validado) e mantém a sugestão de título/descrição num módulo novo porque o prompt do corte fala de "cortes de falas… no acervo da Câmara" — semântica errada para card/foto de campanha. Regras de preenchimento (fail-closed, sem inventar): cidade só quando a menção é **inequívoca** (exatamente 1 município no texto), região derivada; instituição só quando casa o catálogo; temas do merge validado; título/descrição com fallback determinístico (título atual/filename + tipo/temas/cidade). Tudo escrito somente nos campos ainda **não curados** (D6). Sem LLM quando não há texto (Foto/Card) — e sem `scopes`/pessoas/programas/projetos no schema (só o que o design fixa; revisitar no S28).
**Rejeitadas:** **B** porque arrastaria janela/segmento e o prompt errado para dentro do dono do corte (o C167), acoplando dois domínios; **C** porque o aceite pede título/descrição automáticos como ponto de partida.
### D6 — Escrita concorrente: job × curadoria
**Opções:** A) rastrear curadoria **por campo** (`curatedFields`): o job relê a linha fresca dentro da transação e só escreve campo não curado; a edição humana marca os campos que enviou; B) last-write-wins; C) a primeira edição congela toda a catalogação automática.
**Recomendação:** **A** — é a única que impede o clobber silencioso nos dois sentidos (o job não apaga edição humana; a edição parcial não apaga estado do job, porque o update da action envia só os campos do form e o job é o único que escreve `processingStatus/step/error`). O job marca seus writes com `context.contentPieceSystemWrite` para o hook de curadoria não os contar como humanos; hooks de derivação (`region`, `searchText`) usam `originalDoc` como fallback para update parcial (padrão `deriveSpeakerNames`, `src/collections/Recording.ts:61-68`).
**Rejeitadas:** **B** porque a assessoria abre a ficha enquanto o job roda (é o fluxo do design) e um job lento sobrescreveria o título recém-editado; **C** porque um ajuste cedo no título bloquearia transcrição/temas, contrariando "catalogação é ponto de partida".
### D7 — Slug canônico (URL pública do S27)
**Opções:** A) gerado na **primeira transição para `publicado`** (do título vigente), imutável depois; rascunho tem `slug: null`; B) gerado no create a partir do título/filename, imutável; C) re-derivado a cada edição de título.
**Recomendação:** **A** — o título nasce do filename e é substituído pela catalogação; gerar no publish dá URL de qualidade (`/conteudos/fim-da-escala-6x1-e-saude`) e mantém o contrato do S27 (estável/único). Unicidade com probe + `acquireTextAdvisoryLocks` no hook, no precedente `CampaignDemand.ts:109-140` (candidatos `-2`, `-3`…), rodando dentro da transação da action de publicação. Despublicar **não** limpa slug nem `publishedAt` (Reel: sair de publicado não apaga o carimbo).
**Rejeitadas:** **B** porque o slug congelaria a versão-filename ("fala-sus"); **C** porque quebraria link compartilhado a cada rename (contrato do S19: URL nova exige slug novo, não mutação).
### Componentes / mudanças
- **`src/lib/contentPiece.ts`** (novo, puro, sem `server-only`): vocabulário (`CONTENT_PIECE_TYPES/STATUSES/PROCESSING_STATUSES/STEPS` + labels), limites (`CONTENT_PIECE_TITLE_MAX_LENGTH`, `CONTENT_PIECE_MAX_BYTES`, `CONTENT_PIECE_BATCH_MAX_FILES`, `CONTENT_PIECE_TEXT_MAX_BYTES`), regras (`contentPieceTypeFromMime`, `contentPieceFileTypeAllowed`, `needsProcessing`, `canRetryContentPiece`, `canServeContentPieceMedia`, `contentPieceIsPublic`), derivadores puros (`contentPieceTitleFromFilename`, `contentPieceSlugCandidates`, fallback de metadados) e o view model do item de lista — espelho enxuto de `src/lib/recording.ts:17-153`, **sem** copiar segmentos/diarização.
- **`src/lib/schemas/contentPiece.ts`** (novo): zod de entrada + mensagens seguras (`CONTENT_PIECE_FORBIDDEN_MESSAGE`, `CONTENT_PIECE_NOT_FOUND_MESSAGE`, `CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE`, `CONTENT_PIECE_GENERIC_ERROR_MESSAGE`, `CONTENT_PIECE_LINK_INVALID_MESSAGE`, `CONTENT_PIECE_LINK_DUPLICATE_MESSAGE`), no molde de `src/lib/schemas/recording.ts`.
- **`src/collections/ContentPiece.ts`** / **`src/collections/ContentMedia.ts`** (novos): campos/access de D2/D1; hooks `setCanonicalContentPieceSlug` (D7), `deriveContentPieceRegion`, `deriveContentPieceSearchText`, `trackContentPieceCuratedFields` (D6), `stampContentPiecePublishedAt` (Reel), `stampCampaignCreatedBy`; `beforeDelete` não é necessário (sem coleção dependente).
- **`src/utilities/access/contentPieces.ts`** (novo): `canReadContentPiece`/`canCreateContentPiece`/`canUpdateContentPiece`/`canDeleteContentPiece` — predicados **próprios**, nunca alias, como `src/utilities/access/recordings.ts:15-44`; reexport no barrel `src/utilities/campaignAccess.ts:180-186`.
- **`src/utilities/content/`** (subpasta de domínio — evita o pin de top-level, `tests/unit/codebaseConventions.unit.spec.ts:409-556`):
- `contentPieceUpload.ts` — `receiveContentPieceUpload` (cria linha → stream p/ temp com teto → cria `contentMedia` em transação → agenda job) e `attachContentPieceMedia` (peça-link ganha o original; recusa quando já há mídia), no molde de `recordingUpload.ts:68-144`.
- `contentPieceScheduler.ts` — `startContentPieceJobInBackground` com `after()`.
- `contentPieceJob.ts` — `runContentPieceJob` (branch por tipo; reusa `deepInfraTranscribeSegments` `src/utilities/ai/deepInfraTranscribe.ts:98-137`, `buildRecordingAudioFfmpegArgs`/`mergeChunkTranscriptions`/`RECORDING_AUDIO_CHUNK_SECONDS` de `src/lib/recordingTranscription.ts:10-141`, `downloadPrivateMediaToFile` de `privateMediaResponse.ts:193-223`; saving transacional com releitura fresca, D6) + `reapStaleContentPiece` (60 min) — espelho de `recordingJob.ts:126-363`.
- `contentPieceCataloging.ts` — `catalogContentPiece` (D5) + `suggestContentPieceMetadata` (never-throws/fallback).
- `contentPieceLink.ts` — `normalizeContentPieceLink`, `extractInstagramMediaUrl` (Graph API, casa permalink; nunca lança), `contentPieceOriginFromLink`.
- `contentPieceListUrl.ts` / `contentPieceListFilters.ts` — contrato de URL (q, page, `type[]`, `status[]`, `processing[]`) sobre `campaignListUrl.ts:117-153`, no molde de `recordingListUrl.ts:114-134`.
- `contentPiecePageData.ts` / `contentPieceData.ts` / `contentPieceViewModels.ts` — loaders da lista/detalhe e view models (busca `ILIKE` em `search_text` com índice trigram; `columnVisibility` via `readCampaignColumnVisibility('conteudos')`, novo id em `src/lib/campaignColumnVisibility.ts:20-35`).
- **`src/app/(campaign)/campanha/actions/contentPieces.ts`** (novo): `updateContentPieceForActor` (ficha + `curatedFields`), `setContentPiecePublishedForActor` (kill switch, precedente `actions/reels.ts:29-57` + transação do slug D7), `retryContentPieceForActor` (update condicional `falhou`→`processando`, precedente `actions/recording.ts:67-96`), `getContentPieceStatusesForActor` (poll ≤ 50 + reaper), `addContentPieceByLinkForActor` (D4), `deleteContentPieceMediaForActor`? **não** — sem delete neste item (ver Não escopo).
- **Rotas** (`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/`): `page.tsx` (lista, `requireCampaignPageActor({ gate: 'communicationCatalog' })`, `src/utilities/campaignPageActor.ts:81-83`), `[id]/page.tsx` (ficha; título próprio via `SetCampaignPageChrome`), `enviar/route.ts` (POST raw body — **allowlist** do sweep de POST, `tests/unit/codebaseConventions.unit.spec.ts:202-229`), `[id]/arquivo/route.ts` (GET serve + POST anexa raw body — segunda entrada na allowlist), `[id]/retry/route.ts`, `[id]/publicacao/route.ts`, `status/route.ts`, `link/route.ts` (os quatro últimos via `campaignJsonMutationRoute`, `src/utilities/campaignJsonMutationRoute.ts:98-119`), `formActions.ts` (casca de `runCampaignFormAction`, `src/utilities/campaignFormActionError.ts:73-105`), `types.ts` (envelopes das rotas).
- **`src/components/campaign/content/`** (novo): `ContentPieceTable` (colunas como dado: Peça · Processamento · Publicação · Próxima ação — `CampaignTable.tsx:27-63,155-224`), `ContentPieceResultList/Card` (mobile 390, no molde de `RecordingResultList.tsx`), `ContentPieceUploadDialog` (multi-arquivo, XHR + `upload.onprogress` por arquivo, "Tentar de novo"/"Concluir lote", no molde de `RecordingUploadDialog.tsx:119-172`), `AddContentPieceLinkDialog` (com o bloco "Sem scraping." + estados "Extraída"/"Peça-link"), `ContentPieceForm` (ficha: Título, Descrição, Tipo, Data, Temas, Cidade·região, Instituição, Transcrição + Publicar/Despublicar + Salvar), `ContentPieceAttachFileDialog`, `ContentPieceStatusBadge`, `ContentPieceStatusRefresher` (poll 5 s, molde `RecordingStatusRefresher.tsx:32-62`), `ContentPieceRetryButton`.
- **Nav / paths / chrome:** `CAMPAIGN_COMMUNICATION_CONTEUDOS = '/campanha/comunicacao/conteudos'` + helpers em `src/lib/campaignPaths.ts:53-78`; sub-item "Conteúdos" em `communicationSubItems` (`src/components/campaign/shell/nav.ts:63-67`); entrada `conteudos` no catálogo + regra de path antes do regex genérico (`src/lib/campaignPageChrome.ts:119-137,296-325`).
- **S3:** `contentMedia: true` em `s3Storage.collections` (`src/payload.config.ts:176-195`) — sem essa entrada o arquivo cai no disco efêmero do container em produção (modo de falha do C199/C193).
- **Revalidação (costura mínima com o S27):** todo write da peça (create/update/publish/job) chama `revalidateTag(getCollectionListingTag('contentPiece'))` de `src/utilities/documents.ts:26-28` (mesmo dono do tag dos jingles) — nenhum `unstable_cache` novo aqui.
- **Migration:** `20260922_<HHMMSS>_add_content_piece` (D2) + registro em `src/migrations/index.ts` (gerado); índice trigram à mão.
- **Access / Consent:** `contentPieces.ts` (D1/D2); **sem chave de Consent** — registrado: não há PII de eleitor, opt-in nem art. 11 neste item.
- **UI:** Impeccable C — fluxo novo na vertical; port **classe-a-classe** do design aprovado (4 cenas desktop + 2 mobile 390), shape→craft→critique→polish sobre os shells existentes (`CampaignPageShell`, `CampaignTable`, `CampaignListPendingBoundary/Results`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignSearchInput`/omnibox, `CampaignColumnPicker`).
- **Changelog:** `docs/changelog/2026-09-22-c211.md` (uma entrada curta; nunca editar o agregado/HISTORY).
### Dados → forma (se aplicável)
N/A — superfície interna de trabalho, sem KPI, agregado ou série; a intenção declara "sem contadores de vaidade e sem score de busca exposto". O único dado derivado é a catalogação (metadados + transcrição/texto), que é conteúdo da peça, não apresentação de dado.
## Desvios registrados na execução (2026-09-23)
- **`contentPieceSegment` continua fora** e `curatedFields` é escrito pela **action** da ficha (não por hook): o job apenas lê a lista fresca dentro da transação e filtra o que escreve. Mais simples que marcar writes de sistema por `context` e igualmente à prova de clobber.
- **Revalidação em `afterChange` da collection** (não em cada caller): `revalidateContentPiecesListing` (`src/utilities/documents.ts`) busta o tag `contentPieces` em todo write da peça — job, ficha, kill switch —, que é a costura de cache do S27.
- **Peça `texto` arquiva o arquivo**: a extração preenche a transcrição, mas o `contentMedia` é criado como em qualquer tipo, para "despublicar preserva o arquivo" e o download valerem também para texto.
- **`canDeleteContentPiece` não existe**: o delete da collection é `payloadAdminOnly` (não há superfície de apagar peça neste item; o plano já registrava isso em Não escopo).
- **`canServeContentPieceMedia` não existe como predicado**: a rota interna serve quando há `media`; o predicado do contrato público é `contentPieceIsPublic` (puro, consumido pelo S27).
- **Sem pin em `tests/helpers/campaignFixtures.ts`**: o int spec limpa as próprias linhas (precedente C199 — recordings também não entram na union), então não há vazamento; a union fica para quando um segundo spec precisar.
- **`instagramFeed.InstagramPost` ganhou `mediaUrl`** (o `MEDIA_FIELDS` já pedia `media_url`; só não era exposto) — extensão aditiva no dono, sem segundo fetch.
- **A lista não reapa no render**: o reparo de `processando` velho vive no action de status (chamado pelo poll de 5 s), como no C199; o loader ficou só leitura.
- **Índice em `origin` não foi criado** (nenhuma query filtra por ele hoje); `topics`/`tipo`/`status`/`processingStatus`/`municipality`/`piece_date`/`slug`/`sourceUrl` têm índice.
## Fases verificáveis
1. **Tracer / schema+server — quota ~40%:** `src/lib/contentPiece.ts` + `src/lib/schemas/contentPiece.ts`; collections `ContentPiece`/`ContentMedia` + access + barrel; `pnpm migrate:create add_content_piece` (com o trigram à mão) + `pnpm migrate` + `pnpm generate:types`; `contentPieceUpload`/`contentPieceScheduler`/`contentPieceJob` para **vídeo**; `enviar`/`status`/`retry`/`publicacao`/`[id]/arquivo`; action de publish com slug; unit + int do caminho felizes e do retry verdes antes de seguir.
2. **Link + catalogação — quota ~20%:** `contentPieceLink` (Graph API com fetch injetável), `contentPieceCataloging`, `texto`/`foto`/`card` no job, dedup de `sourceUrl`; unit/int de link sem caminho oficial e com caminho mockado.
3. **UI (port classe-a-classe) — quota ~30%:** lista (tabela + cards mobile), diálogo de lote, diálogo de link, ficha editável + Publicar/Despublicar + Reprocessar + Anexar original, nav/chrome/paths; pins de nav/chrome atualizados.
4. **Gates — quota ~10%:** `pnpm gate:fast` (lint + typecheck + unit); `pnpm test:int` (suíte cheia: 100 arquivos verdes); `pnpm check:cycles`/`knip` limpos; e2e da superfície (`campaignSpeechAcervo.e2e.spec.ts`, 31 testes, incl. os 7 novos de C211) verde; `pnpm push` → PR `--base main`. Verificação viva feita pelos próprios e2e (rotas reais: lista, ficha, publish/unpublish, arquivo com range/download, link/duplicado).
## Testes previstos
- **`tests/unit/contentPiece.unit.spec.ts`** (novo): vocabulário/labels; `needsProcessing` (foto/card não; vídeo/áudio/texto sim); `canRetryContentPiece` (só `falhou`); `canServeContentPieceMedia`/`contentPieceIsPublic`; `contentPieceTypeFromMime`/allowlist; título a partir do filename; candidatos de slug; normalização de link (IG `/p/`/`/reel/`/`/reels/`, YT `watch?v=`/`youtu.be`/`shorts`, remoção de `igsh`/`utm_*`, host inválido → null); fallback de metadados; `parseContentPieceListParams`/`buildContentPieceListWhere`/canonicalização de URL.
- **`tests/unit/contentPieceCataloging.unit.spec.ts`** (novo, deps injetadas): sem texto → não escreve nada; 1 município → cidade+região; >1 → cidade vazia; instituição só com match de catálogo; LLM falhando → fallback determinístico; `curatedFields` respeitado.
- **`tests/int/contentPiece.int.spec.ts`** (novo, molde `tests/int/recording.int.spec.ts`): access negado (advisor/leader) na collection e nas actions, communicator ok; upload → `processando`/`extraindo` → job (ffmpeg fake + transcritor injetado) → `pronto` com transcrição, duração, temas, cidade, `searchText`, arquivo preservado; falha → `falhou` + step + erro + arquivo; retry só de `falhou` e dois retries concorrentes = 1 job; reaper (`processando` velho → `falhou`; `uploading` órfão → apagado); `texto` extrai o conteúdo sem transcrever; `foto`/`card` nascem `pronto`; publish carimba `publishedAt` e gera slug 1×, colisão gera `-2`, despublicar preserva slug/publishedAt, republicar mantém o slug mesmo com título novo; `curatedFields` protege o título editado durante o job; link sem token → peça-link sem mídia; link com Graph API mockada → mídia extraída + job; link duplicado → mensagem segura e sem segunda linha; rota `[id]/arquivo` 404 para negado/anon e 200 com range para communicator; loader da lista busca por título/tema/cidade e filtra por tipo/status/processamento.
- **e2e:** nova seção "conteúdos (C211)" em `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` (precedente da seção C199 no mesmo arquivo): fixture `pronto` via payload → lista desktop (tabela) e mobile (cards) → ficha → Publicar → badge "Publicado" → Despublicar; peça-link mostra "Peça-link"; upload real pelo diálogo gera linha com estado honesto (`Processando`/`Falhou`). A seleção curada roda pelo manifesto.
- **Fixtures:** `contentPiece`/`contentMedia` entram na union `CampaignCollection` e na ordem de cleanup de `tests/helpers/campaignFixtures.ts:33-50,1017-1045` (peça antes da mídia, na direção da FK).
## Pins a atualizar (valores exatos)
- **`tests/unit/campaignNav.unit.spec.ts:101-133`** — `expectedSubHrefs` ganha `CAMPAIGN_COMMUNICATION_CONTEUDOS` e a lista de títulos ganha `'Conteúdos'` (o teste de overflow herda).
- **`tests/unit/campaignPageChrome.unit.spec.ts:61-86`** — pin novo: `/campanha/comunicacao/conteudos` → `{ title: 'Conteúdos' }`; `/campanha/comunicacao/conteudos/7` → `null`.
- **`tests/unit/codebaseConventions.unit.spec.ts:202-229`** — allowlist com duas entradas e motivo: `.../conteudos/enviar/route.ts` (upload raw-body) e `.../conteudos/[id]/arquivo/route.ts` (anexo raw-body) — "cookie-authenticated via campaign-token + explicit same-origin check; cannot ride the JSON wrapper".
- **`tests/unit/codebaseConventions.unit.spec.ts:409-556`** — **intocado**: nenhum módulo novo no top-level de `src/utilities/` (tudo em `src/utilities/content/`).
- **`scripts/lib/e2e-affected-manifest.mjs:355-382`** — adicionar `src/components/campaign/content`, `src/utilities/content` e `src/lib/contentPiece` à entrada da vertical de comunicação (specs `campaignSpeechAcervo`, `campaignSpeechCut`, `campaignReel`) — a área de risco `src/utilities/access`/`src/lib/schemas` já está mapeada.
- **`tests/helpers/campaignFixtures.ts:33-50`** — union + `emptyOwnedIDs` + ordem de cleanup.
## Rabbit holes / Não escopo (engenharia)
- **Fila/worker genérico, retomada, chunking de upload, retry global, tempo real** — cortados pela intenção; o lote é fan-out do cliente + estado por peça.
- **Entidade de lote / token HMAC / batch collection** — rejeitada em D3.
- **Segmentos com timestamp, diarização AssemblyAI, faceta de pessoa** — vocabulário e mecanismos do C199; não copiar cego (D2).
- **oEmbed / scraping / download de mídia de terceiro / varredura do perfil** — o mecanismo é o C212; aqui só o link manual e a conta própria por Graph API.
- **Página pública, filtros públicos, OG, share, `SHARE_LINK_RESERVED_SLUGS`, serving público** — S27; aqui só `status`/`slug`/`media` + a tag de revalidação.
- **Gerar thumbnail/poster das peças, player com trechos, editor de mídia, gerador de card** — o design usa placeholder de asset; preview real é o arquivo na ficha.
- **Delete de peça pela Central** — não está nos literais/design; o admin remove se preciso. Gatilho de revisitação: se a assessoria pedir "apagar peça", item curto próprio (com o mesmo padrão do delete de gravação, `actions/recording.ts:232-269`).
- **Contadores/views/score de busca, analytics** — C213.
- **`scopes`/`mentionedPeople`/`mentionedPrograms`/`mentionedProjects` no schema** — sem consumidor hoje (S28 decide).
- **Segundo gate de comunicação, Consent novo, cadastro paralelo a `Contact`** — proibidos.
## Riscos e mitigação
- **Job perdido em restart/deploy** (não há fila): reaper preguiçoso no poll de status marca `processando` velho como `falhou` e apaga `uploading` órfão — mesma classe do C199 (`recordingJob.ts:337-363`); o arquivo é preservado e "Reprocessar" reusa a linha.
- **Rascunho vazando por URL de mídia:** `contentMedia` é privada e o único serving interno é sob `/campanha` com gate; a rota responde 404 silencioso a negado/anon (padrão `[id]/arquivo/route.ts` do C199).
- **Clobber entre job e curadoria:** D6 (`curatedFields` + releitura fresca na transação); teste int dedicado.
- **Slug duplicado/instável:** unique index + probe sob advisory lock no primeiro publish (D7); despublicar não limpa.
- **Link colado de post que não está nas mídias recentes da conta:** extraction falha fechada → peça-link; nunca cria linha pela metade (o arquivo baixado só vira `contentMedia` quando o download completa; a linha nasce antes, e falha de download marca `falhou` com a linha preservada — mesma escolha do C199).
- **Token do Instagram inválido/expirado:** a extração degrada para peça-link (fail-closed, sem erro na cara da assessoria); o estado do token continua no painel do global (S11), que não é tocado.
- **`searchText` dessincronizado em update parcial:** hook de derivação com fallback `originalDoc` (padrão `deriveSpeakerNames`); teste int de publish (que envia só `status`).
- **Custo/limite da Graph API:** listar até 100 mídias recentes por link; uma chamada por link adicionado (não é varredura); se o post for mais antigo que a janela, cai em peça-link — aceito e documentado.
- **S3 sem a entrada da collection:** boot não quebra, mas o arquivo cai no disco efêmero em prod (o modo de falha que o comentário do C199 registra em `src/payload.config.ts:179-184`) — mitigação: entrada no mesmo PR + verificação viva no deploy de staging.
- **PR roda suíte ampla** (toca `src/utilities/access` e `src/lib/schemas`, risk prefixes do OPS86): esperado, nunca zero e2e; rodar `gate:fast` + int local antes do push.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: lote com estado por peça ("Processando"/"Pronto"/"Falhou"), transcrição para Vídeo/Áudio, texto extraído para Texto, Foto/Card sem transcrição, catalogação automática editável, link IG/YT com extração por caminho oficial e peça-link como fallback, Rascunho/Publicado com despublicar preservando arquivo, "Reprocessar".
- [ ] Guardrails: advisor/leader negados (fail-closed) na página, actions, collection e mídia; sem scraping, sem download de terceiro, sem PII, sem Consent novo; sem segunda cópia de mídia que já tem dono; URL pública não criada (S27).
- [ ] Invariantes AGENTS/engineering-standards: Local API com `user` + `overrideAccess: false` (bypass só com comentário); escrita multi-collection em transação com `req: { transactionID }`; identificadores em inglês/copy pt-BR; migrations existentes intocadas; collection nova na lista do S3.
- [ ] Testes de domínio previstos (unit/int) onde access e write paths mudam; pins de nav/chrome/conventions/manifest atualizados; fixtures com cleanup.
- [ ] `pnpm gate:fast` verde; `pnpm test:int` do spec novo verde; e2e curado verde; `pnpm push` via GitHub.
## Decisões de engenharia
- **D1 — Upload/serving:** collection **nova** `contentMedia` (privada, no S3) + rota autenticada sob `/campanha`; S27 serve o publicado pela mesma mídia, sem segunda cópia. Rejeitadas: `media` (rascunho vazaria) e reuso de `recordingMedia`/`reelMedia` (acopla donos).
- **D2 — Modelo:** **uma** collection `contentPiece` + `contentMedia`, sem segmentos; enums pt-BR (`video|foto|texto|audio|card`, `rascunho|publicado`, `processando|pronto|falhou`, `extraindo|transcrevendo|catalogando|salvando`); `topics` reusa `SPEECH_TOPICS`; cidade única + região derivada; `searchText` com trigram à mão; migration `20260922_<HHMMSS>_add_content_piece`. Rejeitadas: `contentPieceSegment` (sem consumidor) e duas collections arquivo/link (duas verdades).
- **D3 — Pipeline/lote:** 1 request/arquivo raw-body (padrão C199) + fan-out no cliente (≤ 2, ≤ 50 arquivos, 4 GiB/arquivo) + `after()` + reaper 60 min + retry condicional; sem entidade de lote; `foto|card` nascem prontos. Rejeitadas: batch collection/token HMAC (estado a mais) e multipart (buffer/isolamento).
- **D4 — Link:** extração só pela Graph API da conta própria (listar mídia e casar permalink) com fallback peça-link; YouTube/terceiros só link; `sourceUrl` normalizada e **única**; sem oEmbed (C212 decide). Rejeitadas: oEmbed agora (não entrega arquivo; invade o C212) e só-link (contraria o gate).
- **D5 — Catalogação:** gazetteer puro + `classifySpeech` reusado + `suggestContentPieceMetadata` novo (never-throws/fallback); cidade só quando inequívoca; instituição só com match de catálogo. Rejeitadas: generalizar o prompt de corte (semântica errada) e só gazetteer (sem título/descrição automáticos).
- **D6 — Concorrência job×curadoria:** `curatedFields` por campo + releitura fresca na transação; o job só escreve campo não curado. Rejeitadas: last-write-wins (clobber) e congelar tudo após editar (mata o enriquecimento).
- **D7 — Slug:** gerado no **primeiro publish** do título vigente, único sob advisory lock, imutável depois; despublicar preserva. Rejeitadas: no create (URL ruim) e re-derivado (quebra link).
- **Serving público:** **não** implementado aqui — costura explícita (status/slug/media + `canServeContentMedia` + tag `contentPieces`); S27 decide URL e cache.
- **Sem Consent novo** — peça interna sem PII de eleitor; registrado, não presumido.
## Self-score
**Self-score decision-quality: 4/5.** (1) Decisões caras (collection/serving, schema/slug, pipeline/lote, link, catalogação, concorrência) têm opções + recomendação + rejeitadas explícitas — mas o schema nasce sinalizado para confirmação humana, o que impede o 5. (2) Cabe no appetite herdado: reusa os mecanismos profundos do C199/puros e corta fila, scraping, segmentos e serving público. (3) Rabbit holes nomeados (fila, batch, segmentos, oEmbed, delete, poster, facetas extras). (4) Depth check: shells de lista, `campaignJsonMutationRoute`, `runCampaignFormAction`, mídia privada, taxonomias, `after()`/reaper/retry e tags são reusados; módulos novos só onde não há dono (vocabulário da peça, catalogação da peça, link da peça). (5) Outcome preservado: a engenharia não reescreve o aceite nem invade o S27/C212/C213.

View file

@ -0,0 +1,54 @@
# Escala e DRY pós-C211 (Central de Conteúdos)
Status: rascunho
Atualizado em: 2026-09-23
Issue: #1267 (`depends: [1254]`)
Item do roadmap: — (follow-up de engenharia da entrega C211)
Responsável: —
Appetite: ~0,5–1 dia eng (fill-in; três fases pequenas, sem migration)
Impeccable: A — só backend/refactor
## Contexto
A entrega C211 ([central-conteudos-ingestao.md](central-conteudos-ingestao.md), Issue #1254) criou o pipeline de peças reusando deliberadamente os mecanismos do C199 (job `after()` + reaper + retry condicional, mídia privada, transcrição, classificador de facetas). A passagem `/simplify` (2 revisores, 0 achados altos) aplicou ~18 correções na própria sessão — `revalidateTag` do listing na collection, passo morto no job, narrowing de relação, texto arquivando o arquivo, `filename` no anexo, mensagens de limite alcançáveis, `SetCampaignPageChrome`, labels pt-BR dos campos curados, guard de anexo no util, pin do chrome, entre outras. O que sobrou são débitos **maiores que o cleanup da sessão** e do mesmo lote (mesma superfície: `src/utilities/content/` + testes novos). Sem este item: (a) o guard de stream com teto e o `staticDirOf` da mídia privada passam a ter 3ª/4ª cópia (o dono `src/utilities/privateMedia/` já existe); (b) os `select` da peça e o ponto do reaper ficam re-escritos entre action/loader/rotas; (c) a marcação de campos curados na action carrega generalidade morta; (d) asserts fracos nos specs novos deixam regressões passarem.
## Objetivos
- O guard de stream com teto (raw body e resposta HTTP) e o `staticDirOf` da mídia privada vivem **num dono único** em `src/utilities/privateMedia/` (ou no upload owner), reusados por C199/C211/speech sem mudar teto, mensagens ou comportamento.
- `contentPieceSelect`/selects do loader da peça vivem num módulo do domínio e o ponto de `reapStaleContentPiece` é único (hoje: status action; o loader é read-only desde a sessão).
- A marcação de `curatedFields` na action da ficha perde a tupla+loop (o form envia todos os campos; `title`/`type` são obrigatórios no schema).
- Asserts dos specs de C211 apertados: valores exatos de mídia/arquivo onde hoje há `toBeTruthy()`; sem round-trip no-op no e2e.
- Guardrails: sem migration; sem mudança de comportamento visível; `overrideAccess`/transação inalterados; `pnpm gate:fast` + int + e2e da vertical verdes.
## Fases
1. **F1 — dono do stream/staticDir de mídia privada (score 3, maior ROI).** Extrair `streamBodyToFile`/`streamResponseToFile`/`staticDirOf` para o dono (`src/utilities/privateMedia/`), migrar `recordings/recordingUpload.ts`, `recordings/recordingJob.ts`, `content/contentPieceUpload.ts`, `content/contentPieceLink.ts`, `content/contentPieceJob.ts` (e `speech/speechMediaPipeline.ts` se couber sem arrastar escopo). Prova: unit do guard (teto por chunk/Content-Length mentiroso) + int/e2e existentes verdes.
2. **F2 — resíduos DRY do domínio `content`.** Selects da peça num módulo (`contentPieceData.ts` ou o loader) reusados pela action/rotas; ponto único do reaper; `curatedFields` sem generalidade morta. Prova: int de C211 verde sem mudança de asserção.
3. **F3 — apertar os asserts.** `tests/int/contentPiece.int.spec.ts` e a seção C211 do e2e: valores exatos de `media`/`filesize`, remover `replace('Peça ', '')`. Prova: os próprios specs.
## Já resolvido no simplify (não reabrir)
- `revalidateTag` do listing na collection (`afterChange` → `revalidateContentPiecesListing`).
- Passo `catalogando` morto no job; duração só quando medida; narrowing de `media` na resolução de fonte.
- Peça `texto` arquivando o arquivo; `filename` preservado no anexo; guard de "já tem arquivo" no util (não só na rota).
- `CONTENT_PIECE_UPLOAD_SAFE_MESSAGES` compartilhado e mensagens de limite alcançáveis.
- `SetCampaignPageChrome` na ficha + seta única; preview de texto; labels pt-BR de `curatedFields`; `postCampaignJson` no diálogo de link; `contentPieceStepLabels` como dono único do passo; loader sem reaper (write fora do render); sem re-fetch do município em update parcial.
## Explicitamente fora (descartes e defers com gatilho)
- **VM carrega `statusLabel`/`processingLabel`/`retryHref`/`isPublished` sem consumidor de produção** — descartar: é o padrão da casa (os VMs de `recording`/`reel` fazem igual; o VM é o contrato de apresentação do wire).
- **`deriveContentPieceCatalogIndex` lê o município quando a relação é tocada (1 query por save de ficha)** — descartar: micro-otimização sem N+1 real.
- **Bloco "Sem mídia oficial" da ficha para peça de arquivo** — descartar: ramo defensivo correto para peça-link (a linha de arquivo só nasce com mídia).
- **Pin de `contentPiece`/`contentMedia` em `campaignFixtures`** — defer, gatilho: 2º spec que precise das fixtures (o int spec limpa as próprias linhas; precedente C199).
- **`normalizeForSearch` não dobra `×`→`x`** — defer, gatilho: uso real da variante tipográfica na base **ou** próximo item que já toque o normalizador/backfill de `search_text` (aí o backfill + trigram vêm de carona).
- **Picker de município (435 `<option>` no `<select>` nativo)** — defer, gatilho: 2º consumidor do picker (filtros públicos do S27/S28) ou feedback real de UX na ficha.
## Self-score (decision-quality)
1. Decisões caras com rejeitadas? **Sim** — o lote é cheap_polish; nada de access/LGPD/schema/unicidade entra (piso 4–5 não se aplica), e os descartes/defers estão nomeados com gatilho.
2. Cabe no appetite? **Sim** (~0,5–1 dia, três fases pequenas, sem migration).
3. Rabbit holes nomeados? **Sim** — extrair helper com um call site (defer), refatorar o normalizador compartilhado (defer), picker com um consumidor (defer).
4. Depth check? **Sim** — reusa o dono de mídia privada existente; não cria módulo de pureza.
5. Outcome do C211 intocado? **Sim** — só estrutura interna e asserts.
**Score: 5/5.**

View file

@ -358,22 +358,29 @@ export const E2E_AFFECTED_MANIFEST = [
// the `communicationCatalog` role gate. `src/lib/speech*` carries the pure
// search/highlight modules the RSC list renders with. C167 adds the cut
// routes/player/dialog and the public cut page below. C194 adds the reel
// library (list/detail/downloads/kill switch) to the same vertical.
// library (list/detail/downloads/kill switch) to the same vertical. C211
// adds the internal Central de Conteúdos (batch upload, link, cataloguing,
// publication kill switch).
prefixes: [
`${CAMPAIGN_APP}/comunicacao`,
'src/components/campaign/speech',
'src/components/campaign/reels',
// C199 — the uploaded recordings source and its private media owner.
'src/components/campaign/recording',
// C211 — the Central de Conteúdos surfaces.
'src/components/campaign/content',
'src/utilities/speech',
'src/utilities/reels',
// C199 — the shared private-media owner and the ffmpeg runner.
'src/utilities/recordings',
// C211 — the content piece pipeline.
'src/utilities/content',
'src/utilities/privateMedia',
'src/utilities/media',
'src/lib/speech',
'src/lib/reel',
'src/lib/recording',
'src/lib/contentPiece',
'src/lib/privateMedia',
// The role predicates drive the vertical gate and the assistant surfaces.
'src/lib/campaignRoles',

View file

@ -0,0 +1,179 @@
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { CONTENT_MEDIA_SLUG, toContentPieceViewModel } from '@/lib/contentPiece'
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
CONTENT_PIECE_MEDIA_ALREADY_ATTACHED_MESSAGE,
CONTENT_PIECE_NOT_FOUND_MESSAGE,
CONTENT_PIECE_UPLOAD_SAFE_MESSAGES,
contentPieceAttachRequestSchema,
} from '@/lib/schemas/contentPiece'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
import { campaignJsonMutationErrorResponse } from '@/utilities/campaignJsonMutationRoute'
import { attachContentPieceMedia } from '@/utilities/content/contentPieceUpload'
import { buildPrivateMediaResponse } from '@/utilities/privateMedia/privateMediaResponse'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import type { ContentPieceAttachResponse } from '../../types'
export const dynamic = 'force-dynamic'
/**
* C211 — the only door to a piece file. Lives under `/campanha` because the
* `campaign-token` cookie is scoped to that path (a `<video>`/`<img>` sends the
* cookie, never an Authorization header). The gate is the same communication
* catalog predicate as the collection access, and every denial is a silent
* `404` so the route never leaks which pieces exist.
*
* `GET` serves the archived file (range/download). `POST` attaches the original
* file to a link piece as a RAW body — the "Anexar arquivo original" action,
* allowlisted in the `codebaseConventions` sweep like the upload route.
*/
const notFound = (): NextResponse =>
new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL },
})
const pieceIdFrom = async (params: Promise<{ id: string }>): Promise<number | null> => {
const { id } = await params
const pieceId = Number(id)
return Number.isInteger(pieceId) && pieceId > 0 ? pieceId : null
}
export const GET = async (
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> => {
const user = await getCampaignUser()
if (!user || !canReadCommunicationCatalog(user.role)) return notFound()
const pieceId = await pieceIdFrom(params)
if (pieceId === null) return notFound()
const payload = await getPayload({ config })
const piece = await payload
.findByID({
collection: 'contentPiece',
id: pieceId,
depth: 1,
select: { media: true },
user,
overrideAccess: false,
})
.catch(() => null)
if (!piece?.media || typeof piece.media !== 'object') return notFound()
const upload = payload.collections[CONTENT_MEDIA_SLUG].config.upload
const staticDir =
upload && typeof upload === 'object' && upload.staticDir ? upload.staticDir : CONTENT_MEDIA_SLUG
return buildPrivateMediaResponse({
media: piece.media,
staticDir,
rangeHeader: request.headers.get('range'),
download: new URL(request.url).searchParams.get('download') === '1',
})
}
export const POST = async (
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<NextResponse<ContentPieceAttachResponse>> => {
const errorResponse = (message: string, status: number) =>
NextResponse.json<ContentPieceAttachResponse>({ status: 'error', message }, { status })
if (!isSameOriginRequest(request)) return errorResponse('Requisição inválida.', 403)
const user = await getCampaignUser()
if (!user) return errorResponse(CAMPAIGN_AUTH_REQUIRED_MESSAGE, 401)
if (!canReadCommunicationCatalog(user.role)) {
return errorResponse(CONTENT_PIECE_FORBIDDEN_MESSAGE, 403)
}
const pieceId = await pieceIdFrom(params)
const searchParams = new URL(request.url).searchParams
const parsed = contentPieceAttachRequestSchema.safeParse({
contentPieceId: pieceId,
filename: searchParams.get('filename') ?? '',
})
if (!parsed.success) {
return errorResponse(
parsed.error.issues[0]?.message ?? CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
400,
)
}
const contentLengthHeader = request.headers.get('content-length')
const parsedLength = contentLengthHeader === null ? null : Number(contentLengthHeader)
const contentLength =
parsedLength !== null && Number.isFinite(parsedLength) && parsedLength >= 0
? parsedLength
: null
try {
const payload = await getPayload({ config })
const current = await payload.find({
collection: 'contentPiece',
where: { id: { equals: parsed.data.contentPieceId } },
depth: 0,
limit: 1,
pagination: false,
select: { title: true, type: true, media: true },
user,
overrideAccess: false,
})
const piece = current.docs[0]
if (!piece) return errorResponse(CONTENT_PIECE_NOT_FOUND_MESSAGE, 400)
if (piece.media) return errorResponse(CONTENT_PIECE_MEDIA_ALREADY_ATTACHED_MESSAGE, 400)
await attachContentPieceMedia({
payload,
actor: user,
piece: { id: piece.id, title: piece.title, type: piece.type, media: piece.media },
filename: parsed.data.filename,
body: request.body,
contentLength,
})
const updated = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
select: {
title: true,
type: true,
status: true,
processingStatus: true,
step: true,
origin: true,
topics: true,
cityLabel: true,
region: true,
durationSeconds: true,
pieceDate: true,
publishedAt: true,
error: true,
media: true,
},
user,
overrideAccess: false,
})
return NextResponse.json<ContentPieceAttachResponse>({
status: 'success',
piece: toContentPieceViewModel(updated),
})
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: CONTENT_PIECE_UPLOAD_SAFE_MESSAGES,
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
})
}
}

View file

@ -0,0 +1,221 @@
import config from '@payload-config'
import { ArrowLeftIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { ContentPieceAttachFileButton } from '@/components/campaign/content/ContentPieceAttachFileButton'
import { ContentPieceForm } from '@/components/campaign/content/ContentPieceForm'
import { ContentPiecePublicationButton } from '@/components/campaign/content/ContentPiecePublicationButton'
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
import {
ContentPieceProcessingBadge,
ContentPiecePublicationBadge,
} from '@/components/campaign/content/ContentPieceStatusBadge'
import { ContentPieceStatusRefresher } from '@/components/campaign/content/ContentPieceStatusRefresher'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { CAMPAIGN_COMMUNICATION_CONTEUDOS } from '@/lib/campaignPaths'
import { contentPieceOriginLabels, contentPieceTopicLabel } from '@/lib/contentPiece'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
ContentPieceNotFoundError,
loadContentPieceDetailPageData,
loadContentPieceFormOptions,
} from '@/utilities/content/contentPiecePageData'
import { updateContentPieceFormAction } from '../formActions'
type ContentPieceDetailPageProps = {
params: Promise<{ id: string }>
}
const notFoundPage = () => notFound()
/**
* C211 — the ficha of one piece (approved design scene 4): preview, origin and
* duration on the left; the editable catalogue and the kill switch on the
* right. The transcript is editable and feeds the public search (S28).
*/
export default async function ContentPieceDetailPage({ params }: ContentPieceDetailPageProps) {
const { id } = await params
const contentPieceId = Number(id)
if (!Number.isInteger(contentPieceId) || contentPieceId <= 0) notFoundPage()
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
const [data, options] = await Promise.all([
loadContentPieceDetailPageData(payload, user, contentPieceId).catch((error) => {
if (error instanceof ContentPieceNotFoundError) notFoundPage()
throw error
}),
loadContentPieceFormOptions(payload),
])
const { piece } = data
return (
<CampaignPageShell aria-label="Peça da Central de Conteúdos">
<SetCampaignPageChrome chrome={{ title: piece.title }} />
<ContentPieceStatusRefresher
pieces={[{ id: piece.id, processingStatus: piece.processingStatus }]}
/>
<div className="mb-6">
<Link
href={CAMPAIGN_COMMUNICATION_CONTEUDOS}
className="text-xs font-semibold text-primary hover:underline"
>
<ArrowLeftIcon className="mr-1 inline size-3.5" aria-hidden="true" />
Conteúdos
</Link>
<div className="mt-2 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-xl font-semibold">{piece.title}</h2>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ContentPieceProcessingBadge status={piece.processingStatus} />
<ContentPiecePublicationBadge status={piece.status} />
</div>
</div>
<ContentPiecePublicationButton contentPieceId={piece.id} status={piece.status} />
</div>
</div>
<div className="grid gap-5 lg:grid-cols-[300px_1fr] lg:gap-8">
<div>
<div className="overflow-hidden rounded-xl border bg-stone-100">
{piece.hasFile && piece.type === 'video' ? (
<video
src={piece.fileHref ?? undefined}
controls
preload="metadata"
className="aspect-video w-full bg-black"
/>
) : piece.hasFile && piece.type === 'audio' ? (
<div className="p-4">
<audio
src={piece.fileHref ?? undefined}
controls
preload="metadata"
className="w-full"
/>
</div>
) : piece.type === 'texto' ? (
<div className="grid aspect-video place-items-center p-4 text-center text-xs font-semibold text-muted-foreground">
Texto
</div>
) : piece.hasFile && piece.fileHref ? (
// eslint-disable-next-line @next/next/no-img-element -- private authenticated media proxy, not a static asset
<img
src={piece.fileHref}
alt={piece.title}
className="aspect-video w-full object-cover"
/>
) : (
<div className="grid aspect-video place-items-center p-4 text-center text-xs font-semibold text-muted-foreground">
{piece.origin === 'arquivo' ? 'Sem arquivo' : 'Peça-link'}
</div>
)}
</div>
<dl className="mt-4 space-y-2 text-xs">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Origem</dt>
<dd className="text-right">{contentPieceOriginLabels[piece.origin]}</dd>
</div>
{piece.sourceUrl ? (
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Link</dt>
<dd className="min-w-0 text-right">
<a
href={piece.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="break-all text-primary hover:underline"
>
{piece.sourceUrl}
</a>
</dd>
</div>
) : null}
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Duração</dt>
<dd>{piece.durationLabel ?? '—'}</dd>
</div>
{piece.topics.length > 0 ? (
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Temas</dt>
<dd className="text-right">
{piece.topics.map(contentPieceTopicLabel).join('; ')}
</dd>
</div>
) : null}
{piece.publishedAtLabel ? (
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Publicado em</dt>
<dd>{piece.publishedAtLabel}</dd>
</div>
) : null}
</dl>
{!piece.hasFile ? (
<div className="mt-5 rounded-lg bg-muted p-3">
<p className="text-xs leading-5 text-muted-foreground">
Sem mídia oficial para extrair. A peça circula pelo link; se quiser arquivar o
original, anexe o arquivo.
</p>
<ContentPieceAttachFileButton contentPieceId={piece.id} className="mt-2" />
</div>
) : null}
{piece.processingStatus === 'falhou' ? (
<div className="mt-5 rounded-lg border border-red-200 bg-red-50 p-3">
<p className="text-xs leading-5 text-red-800">
{piece.failureMessage ?? 'O processamento não foi concluído.'} O arquivo foi
preservado.
</p>
<ContentPieceRetryButton contentPieceId={piece.id} className="mt-2" />
</div>
) : piece.processingStatus === 'processando' ? (
<div className="mt-5 rounded-lg bg-muted p-3">
<p className="text-xs leading-5 text-muted-foreground">
{piece.step === 'transcrevendo'
? 'Transcrevendo e catalogando…'
: 'Preparando a peça…'}{' '}
A ficha fica disponível para revisão assim que terminar.
</p>
</div>
) : null}
</div>
<div>
<ContentPieceForm
piece={{
id: piece.id,
title: piece.title,
description: piece.description,
type: piece.type,
pieceDate: piece.pieceDate,
topics: piece.topics,
municipalityId: piece.municipalityId,
institution: piece.institution,
transcript: piece.transcript,
}}
municipalityOptions={options.municipalities}
formAction={updateContentPieceFormAction}
/>
{piece.fileHref ? (
<div className="mt-4">
<Button asChild variant="outline" className="min-h-11">
<a href={`${piece.fileHref}?download=1`}>Baixar arquivo</a>
</Button>
</div>
) : null}
</div>
</div>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import { setContentPiecePublishedForActor } from '@/app/(campaign)/campanha/actions/contentPieces'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
CONTENT_PIECE_NOT_FOUND_MESSAGE,
contentPiecePublicationRequestSchema,
} from '@/lib/schemas/contentPiece'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { ContentPiecePublicationResponse } from '../../types'
export const dynamic = 'force-dynamic'
/** C211 — the kill switch of one piece ("Publicar" / "Despublicar"). */
export const POST = campaignJsonMutationRoute(
{
bodySchema: contentPiecePublicationRequestSchema,
safeMessages: [CONTENT_PIECE_FORBIDDEN_MESSAGE, CONTENT_PIECE_NOT_FOUND_MESSAGE],
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const piece = await setContentPiecePublishedForActor(body)
return NextResponse.json<ContentPiecePublicationResponse>({ status: 'success', piece })
},
)

View file

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { retryContentPieceForActor } from '@/app/(campaign)/campanha/actions/contentPieces'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
CONTENT_PIECE_NOT_FOUND_MESSAGE,
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
contentPieceRetryRequestSchema,
} from '@/lib/schemas/contentPiece'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { ContentPieceRetryResponse } from '../../types'
export const dynamic = 'force-dynamic'
/** C211 — retries the processing of a failed piece (same row, same job). */
export const POST = campaignJsonMutationRoute(
{
bodySchema: contentPieceRetryRequestSchema,
safeMessages: [
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_NOT_FOUND_MESSAGE,
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
],
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const piece = await retryContentPieceForActor(body)
return NextResponse.json<ContentPieceRetryResponse>({ status: 'success', piece })
},
)

View file

@ -0,0 +1,118 @@
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { contentPieceTypeFromMime, toContentPieceViewModel } from '@/lib/contentPiece'
import {
CONTENT_PIECE_FILE_TYPE_MESSAGE,
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
CONTENT_PIECE_UPLOAD_SAFE_MESSAGES,
contentPieceUploadMetadataSchema,
} from '@/lib/schemas/contentPiece'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
import { campaignJsonMutationErrorResponse } from '@/utilities/campaignJsonMutationRoute'
import { receiveContentPieceUpload } from '@/utilities/content/contentPieceUpload'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import type { ContentPieceUploadResponse } from '../types'
export const dynamic = 'force-dynamic'
/**
* C211 — accepts ONE piece as a RAW body: the request body IS the file, the
* metadata (filename) travels in the query string. The same shape as the C199
* recording upload, for the same reasons: a multi-GB file must stream to disk
* (server actions cap at 1 MB and FormData buffers the whole media), the type
* is derived on the server from the MIME/extension, and XHR still gives the
* batch dialog a real `upload.onprogress` per file.
*
* It cannot ride `campaignJsonMutationRoute` (JSON-body wrapper), so it repeats
* the same-origin guard and the error envelope explicitly and is allowlisted in
* the `codebaseConventions` sweep.
*/
const errorResponse = (message: string, status: number): NextResponse<ContentPieceUploadResponse> =>
NextResponse.json({ status: 'error', message }, { status })
export const POST = async (request: Request): Promise<NextResponse<ContentPieceUploadResponse>> => {
if (!isSameOriginRequest(request)) {
return errorResponse('Requisição inválida.', 403)
}
const user = await getCampaignUser()
if (!user) return errorResponse(CAMPAIGN_AUTH_REQUIRED_MESSAGE, 401)
if (!canReadCommunicationCatalog(user.role)) {
return errorResponse(CONTENT_PIECE_FORBIDDEN_MESSAGE, 403)
}
const searchParams = new URL(request.url).searchParams
const parsed = contentPieceUploadMetadataSchema.safeParse({
filename: searchParams.get('filename') ?? '',
})
if (!parsed.success) {
return errorResponse(
parsed.error.issues[0]?.message ?? CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
400,
)
}
const type = contentPieceTypeFromMime(request.headers.get('content-type'), parsed.data.filename)
if (type === null) {
return errorResponse(CONTENT_PIECE_FILE_TYPE_MESSAGE, 400)
}
const contentLengthHeader = request.headers.get('content-length')
const parsedLength = contentLengthHeader === null ? null : Number(contentLengthHeader)
const contentLength =
parsedLength !== null && Number.isFinite(parsedLength) && parsedLength >= 0
? parsedLength
: null
try {
const payload = await getPayload({ config })
const { id } = await receiveContentPieceUpload({
payload,
actor: user,
type,
metadata: parsed.data,
body: request.body,
contentLength,
})
const piece = await payload.findByID({
collection: 'contentPiece',
id,
depth: 0,
select: {
title: true,
type: true,
status: true,
processingStatus: true,
step: true,
origin: true,
topics: true,
cityLabel: true,
region: true,
durationSeconds: true,
pieceDate: true,
publishedAt: true,
error: true,
media: true,
},
user,
overrideAccess: false,
})
return NextResponse.json<ContentPieceUploadResponse>({
status: 'success',
piece: toContentPieceViewModel(piece),
})
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: CONTENT_PIECE_UPLOAD_SAFE_MESSAGES,
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
})
}
}

View file

@ -0,0 +1,68 @@
'use server'
import { revalidatePath } from 'next/cache'
import { updateContentPieceForActor } from '@/app/(campaign)/campanha/actions/contentPieces'
import { CAMPAIGN_COMMUNICATION_CONTEUDOS } from '@/lib/campaignPaths'
import { isContentPieceType } from '@/lib/contentPiece'
import {
nullableFormText,
repeatedFormTexts,
requiredFormText,
requiredIntegerFormValue,
} from '@/lib/formData'
import {
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
CONTENT_PIECE_SAFE_MESSAGES,
CONTENT_PIECE_TYPE_INVALID_MESSAGE,
} from '@/lib/schemas/contentPiece'
import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
/**
* C211 — the ficha form action. Keeps the route-owned revalidation (list and
* ficha) and maps the domain errors through the shared ladder: the action
* itself owns the gate, the curated fields and the relationships.
*/
export const updateContentPieceFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const contentPieceId = requiredIntegerFormValue(formData, 'contentPieceId')
const type = requiredFormText(formData, 'type')
if (!isContentPieceType(type)) {
throw new Error(CONTENT_PIECE_TYPE_INVALID_MESSAGE)
}
const municipalityValue = optionalMunicipalityId(formData)
await updateContentPieceForActor({
contentPieceId,
title: requiredFormText(formData, 'title'),
description: nullableFormText(formData, 'description'),
type,
pieceDate: nullableFormText(formData, 'pieceDate'),
topics: repeatedFormTexts(formData, 'topics'),
municipalityId: municipalityValue,
institution: nullableFormText(formData, 'institution'),
transcript: nullableFormText(formData, 'transcript'),
})
revalidatePath(CAMPAIGN_COMMUNICATION_CONTEUDOS)
revalidatePath(`${CAMPAIGN_COMMUNICATION_CONTEUDOS}/${contentPieceId}`)
return { message: 'Alterações salvas.' }
},
safeMessages: CONTENT_PIECE_SAFE_MESSAGES,
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
})
const optionalMunicipalityId = (formData: FormData): number | null | undefined => {
if (!formData.has('municipalityId')) return undefined
const raw = String(formData.get('municipalityId') ?? '').trim()
if (!raw) return null
const parsed = Number(raw)
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
}

View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server'
import { addContentPieceByLinkForActor } from '@/app/(campaign)/campanha/actions/contentPieces'
import {
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
CONTENT_PIECE_SAFE_MESSAGES,
contentPieceLinkRequestSchema,
} from '@/lib/schemas/contentPiece'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { ContentPieceLinkResponse } from '../types'
export const dynamic = 'force-dynamic'
/**
* C211 — adds a piece by link. The action owns the URL normalization, the
* duplicate probe and the background extraction; this route is the JSON door.
*/
export const POST = campaignJsonMutationRoute(
{
bodySchema: contentPieceLinkRequestSchema,
safeMessages: CONTENT_PIECE_SAFE_MESSAGES,
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const piece = await addContentPieceByLinkForActor(body)
return NextResponse.json<ContentPieceLinkResponse>({ status: 'success', piece })
},
)

View file

@ -0,0 +1,131 @@
import config from '@payload-config'
import { PackageOpenIcon } from 'lucide-react'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { AddContentPieceLinkDialog } from '@/components/campaign/content/AddContentPieceLinkDialog'
import { ContentPieceCardList } from '@/components/campaign/content/ContentPieceCardList'
import { ContentPieceFilters } from '@/components/campaign/content/ContentPieceFilters'
import { ContentPieceStatusRefresher } from '@/components/campaign/content/ContentPieceStatusRefresher'
import { ContentPieceTable } from '@/components/campaign/content/ContentPieceTable'
import { ContentPieceUploadDialog } from '@/components/campaign/content/ContentPieceUploadDialog'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { buildContentPieceListHref } from '@/utilities/content/contentPieceListUrl'
import { loadContentPieceListPageData } from '@/utilities/content/contentPiecePageData'
export const metadata = campaignPageMetadataFromCatalog('conteudos')
type ContentPiecesPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
/**
* C211 — the internal Central de Conteúdos: every piece the assessoria sent,
* with the honest processing state, the publication kill switch and the two
* ways in (batch upload, add by link). The public Central is S27.
*/
export default async function ContentPiecesPage({ searchParams }: ContentPiecesPageProps) {
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
const [columnVisibility, data] = await Promise.all([
readCampaignColumnVisibility('conteudos'),
loadContentPieceListPageData(payload, user, searchParams),
])
if (data.redirectHref) redirect(data.redirectHref)
const pendingPieces = data.rows.map((row) => ({
id: row.id,
processingStatus: row.processingStatus,
}))
const columns = toCampaignColumnPickerColumns([
{ id: 'piece', label: 'Peça', mandatory: true },
{ id: 'processing', label: 'Processamento' },
{ id: 'publication', label: 'Publicação' },
{ id: 'action', label: 'Próxima ação', mandatory: true },
])
const emptyState = (
<CampaignListEmptyState
icon={PackageOpenIcon}
title="Nenhuma peça na Central ainda"
description="Envie os arquivos do evento ou cole o link de uma publicação do Instagram/YouTube. Cada peça entra como rascunho e sai catalogada."
>
<div className="flex flex-wrap items-center justify-center gap-2">
<ContentPieceUploadDialog />
<AddContentPieceLinkDialog />
</div>
</CampaignListEmptyState>
)
return (
<CampaignPageShell aria-label="Central de Conteúdos">
<ContentPieceStatusRefresher pieces={pendingPieces} />
<CampaignListPendingBoundary>
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">Conteúdos</h2>
<p className="mt-1 text-sm text-muted-foreground">
Peças que alimentam a Central pública. Rascunhos não aparecem no site.
</p>
</div>
{/* Mobile: the primary "Enviar peças" is full width and the link
action is a compact secondary (approved scene 5); desktop keeps
both side by side in the design's order. */}
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row sm:items-center">
<AddContentPieceLinkDialog
triggerLabel={
<>
<span className="max-sm:hidden">Adicionar por link</span>
<span className="sm:hidden">Link</span>
</>
}
triggerClassName="max-sm:w-full"
/>
<ContentPieceUploadDialog triggerClassName="max-sm:w-full" />
</div>
</div>
<ContentPieceFilters
state={data.state}
trailing={
<CampaignColumnPickerTrailing columnVisibility={columnVisibility} columns={columns} />
}
/>
<CampaignListResults>
<ContentPieceCardList rows={data.rows} empty={emptyState} />
<ContentPieceTable
rows={data.rows}
columnVisibility={columnVisibility}
empty={emptyState}
/>
{data.rows.length > 0 ? (
<CampaignListFooter
totalDocs={data.totalDocs}
singular="peça"
plural="peças"
page={data.state.page}
totalPages={data.totalPages}
hrefForPage={(page) => buildContentPieceListHref(data.state, page)}
/>
) : null}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,26 @@
import { NextResponse } from 'next/server'
import { getContentPieceStatusesForActor } from '@/app/(campaign)/campanha/actions/contentPieces'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
contentPieceStatusRequestSchema,
} from '@/lib/schemas/contentPiece'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { ContentPieceStatusResponse } from '../types'
export const dynamic = 'force-dynamic'
/** C211 — polls the statuses of the pieces visible in the list. */
export const POST = campaignJsonMutationRoute(
{
bodySchema: contentPieceStatusRequestSchema,
safeMessages: [CONTENT_PIECE_FORBIDDEN_MESSAGE],
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const pieces = await getContentPieceStatusesForActor(body)
return NextResponse.json<ContentPieceStatusResponse>({ status: 'success', pieces })
},
)

View file

@ -0,0 +1,31 @@
import type { ContentPieceViewModel } from '@/lib/contentPiece'
/** Wire contract of `POST .../conteudos/enviar` (C211) — raw-body upload. */
export type ContentPieceUploadResponse =
| { status: 'success'; piece: ContentPieceViewModel }
| { status: 'error'; message: string }
/** Wire contract of `POST .../conteudos/link` (C211) — add by link. */
export type ContentPieceLinkResponse =
| { status: 'success'; piece: ContentPieceViewModel }
| { status: 'error'; message: string }
/** Wire contract of `POST .../conteudos/status` (C211) — status poll. */
export type ContentPieceStatusResponse =
| { status: 'success'; pieces: ContentPieceViewModel[] }
| { status: 'error'; message: string }
/** Wire contract of `POST .../conteudos/[id]/retry` (C211). */
export type ContentPieceRetryResponse =
| { status: 'success'; piece: ContentPieceViewModel }
| { status: 'error'; message: string }
/** Wire contract of `POST .../conteudos/[id]/publicacao` (C211) — kill switch. */
export type ContentPiecePublicationResponse =
| { status: 'success'; piece: ContentPieceViewModel }
| { status: 'error'; message: string }
/** Wire contract of `POST .../conteudos/[id]/arquivo` (C211) — attach original. */
export type ContentPieceAttachResponse =
| { status: 'success'; piece: ContentPieceViewModel }
| { status: 'error'; message: string }

View file

@ -0,0 +1,311 @@
'use server'
import type { Payload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import {
contentPieceLinkTitle,
isContentPieceTopic,
parseContentPieceLink,
toContentPieceViewModel,
type ContentPieceCuratedField,
type ContentPieceViewModel,
} from '@/lib/contentPiece'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
CONTENT_PIECE_LINK_INVALID_MESSAGE,
CONTENT_PIECE_NOT_FOUND_MESSAGE,
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
contentPieceLinkRequestSchema,
contentPiecePublicationRequestSchema,
contentPieceRetryRequestSchema,
contentPieceStatusRequestSchema,
contentPieceUpdateRequestSchema,
type ContentPieceStatusRequest,
type ContentPieceUpdateRequest,
} from '@/lib/schemas/contentPiece'
import type { SpeechTopic } from '@/lib/speechFacets'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { reapStaleContentPiece } from '@/utilities/content/contentPieceJob'
import { startContentPieceJobInBackground } from '@/utilities/content/contentPieceScheduler'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
/**
* C211 — mutations of the Central de Conteúdos: edit one piece's catalogue,
* move its kill switch, add a piece by link and retry a failed processing. The
* communication gate is repeated fresh before any read or write (same contract
* as the acervo/cut actions); the collection access is the final barrier.
*/
const contentPieceSelect = {
title: true,
updatedAt: true,
type: true,
status: true,
processingStatus: true,
step: true,
origin: true,
topics: true,
cityLabel: true,
region: true,
durationSeconds: true,
pieceDate: true,
publishedAt: true,
error: true,
media: true,
curatedFields: true,
} as const
const loadContentPieceForActor = async (
payload: Payload,
actor: CampaignUser,
contentPieceId: number,
) => {
const result = await payload.find({
collection: 'contentPiece',
where: { id: { equals: contentPieceId } },
depth: 0,
limit: 1,
pagination: false,
select: contentPieceSelect,
user: actor,
overrideAccess: false,
})
return result.docs[0]
}
/**
* Saves the ficha and records what the assessoria curated: every field the form
* carried is added to `curatedFields`, so a running job never overwrites a
* human decision (D6) — including an explicit clear (an empty value is a
* decision, not an invitation to refill).
*/
export const updateContentPieceForActor = async (
input: ContentPieceUpdateRequest,
): Promise<ContentPieceViewModel> => {
const parsed = contentPieceUpdateRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
const current = await loadContentPieceForActor(payload, actor, parsed.contentPieceId)
if (!current) throw new Error(CONTENT_PIECE_NOT_FOUND_MESSAGE)
const curated = new Set(current.curatedFields ?? [])
const curatedOnSave: [ContentPieceCuratedField, boolean][] = [
['title', parsed.title !== undefined],
['description', parsed.description !== undefined],
['topics', parsed.topics !== undefined],
// The request carries the relationship id; the curated vocabulary uses the
// persisted field name.
['municipality', parsed.municipalityId !== undefined],
['institution', parsed.institution !== undefined],
['pieceDate', parsed.pieceDate !== undefined],
['transcript', parsed.transcript !== undefined],
['type', parsed.type !== undefined],
]
for (const [field, touched] of curatedOnSave) if (touched) curated.add(field)
const topics = (parsed.topics ?? [])
.filter(isContentPieceTopic)
.filter((value, index, values) => values.indexOf(value) === index) as SpeechTopic[]
const updated = await payload.update({
collection: 'contentPiece',
id: parsed.contentPieceId,
data: {
title: parsed.title,
type: parsed.type,
description: parsed.description ?? null,
pieceDate: parsed.pieceDate ?? null,
topics,
municipality: parsed.municipalityId ?? null,
institution: parsed.institution ?? null,
transcript: parsed.transcript ?? null,
curatedFields: [...curated],
},
depth: 0,
select: contentPieceSelect,
user: actor,
overrideAccess: false,
})
return toContentPieceViewModel(updated)
}
/**
* The kill switch: `publicado` puts the piece on the public Central (the first
* publication generates its canonical slug inside the transaction), `rascunho`
* takes it off at once and preserves slug, `publishedAt` and file.
*/
export const setContentPiecePublishedForActor = async (input: {
contentPieceId: number
published: boolean
}): Promise<ContentPieceViewModel> => {
const parsed = contentPiecePublicationRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
const current = await loadContentPieceForActor(payload, actor, parsed.contentPieceId)
if (!current) throw new Error(CONTENT_PIECE_NOT_FOUND_MESSAGE)
const updated = await withPayloadTransaction(payload, async ({ req }) =>
payload.update({
collection: 'contentPiece',
id: parsed.contentPieceId,
data: { status: parsed.published ? 'publicado' : 'rascunho' },
depth: 0,
select: contentPieceSelect,
user: actor,
overrideAccess: false,
req,
}),
)
return toContentPieceViewModel(updated)
}
/** Retries the processing of a failed piece (same row, same job). */
export const retryContentPieceForActor = async (input: {
contentPieceId: number
}): Promise<ContentPieceViewModel> => {
const parsed = contentPieceRetryRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
const current = await loadContentPieceForActor(payload, actor, parsed.contentPieceId)
if (!current) throw new Error(CONTENT_PIECE_NOT_FOUND_MESSAGE)
if (current.processingStatus !== 'falhou') throw new Error(CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE)
// Conditional update: two concurrent retries cannot both schedule a job.
const updated = await payload.update({
collection: 'contentPiece',
where: {
and: [{ id: { equals: parsed.contentPieceId } }, { processingStatus: { equals: 'falhou' } }],
},
data: { processingStatus: 'processando', step: 'extraindo', error: null },
depth: 0,
select: contentPieceSelect,
user: actor,
overrideAccess: false,
})
const piece = updated.docs[0]
if (!piece) throw new Error(CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE)
startContentPieceJobInBackground(piece.id)
return toContentPieceViewModel(piece)
}
/**
* Polls the statuses of the visible pieces; a stale `processando` row is reaped
* to `falhou` on the way (there is no queue to ask), then the page is read
* fresh so the returned status is the repaired one.
*/
export const getContentPieceStatusesForActor = async (
input: ContentPieceStatusRequest,
): Promise<ContentPieceViewModel[]> => {
const parsed = contentPieceStatusRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
const where = { id: { in: parsed.contentPieceIds } }
const first = await payload.find({
collection: 'contentPiece',
where,
depth: 0,
limit: parsed.contentPieceIds.length,
pagination: false,
select: contentPieceSelect,
user: actor,
overrideAccess: false,
})
await Promise.all(
first.docs.map((piece) =>
reapStaleContentPiece(payload, {
id: piece.id,
processingStatus: piece.processingStatus,
updatedAt: piece.updatedAt,
}),
),
)
const result = await payload.find({
collection: 'contentPiece',
where,
depth: 0,
limit: parsed.contentPieceIds.length,
pagination: false,
select: contentPieceSelect,
user: actor,
overrideAccess: false,
})
return result.docs.map((piece) => toContentPieceViewModel(piece))
}
/**
* Adds a piece by link: the canonical URL is the identity (unique in the DB), so
* pasting the same post twice answers the same domain message instead of
* creating a twin row. The pipeline resolves the official extraction in the
* background; a link without an official path becomes a peça-link.
*/
export const addContentPieceByLinkForActor = async (input: {
url: string
}): Promise<ContentPieceViewModel> => {
const parsed = contentPieceLinkRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
const link = parseContentPieceLink(parsed.url)
if (!link) throw new Error(CONTENT_PIECE_LINK_INVALID_MESSAGE)
const existing = await payload.find({
collection: 'contentPiece',
where: { sourceUrl: { equals: link.canonicalUrl } },
depth: 0,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
})
if (existing.docs[0]) throw new Error(CONTENT_PIECE_LINK_DUPLICATE_MESSAGE)
try {
// A link is catalogued as a video piece; the extraction reclassifies an
// Instagram image to `foto` and the assessoria can change the type.
const piece = await payload.create({
collection: 'contentPiece',
data: {
title: contentPieceLinkTitle(link),
type: 'video',
origin: link.origin,
sourceUrl: link.canonicalUrl,
status: 'rascunho',
processingStatus: 'processando',
step: 'extraindo',
},
depth: 0,
user: actor,
overrideAccess: false,
})
startContentPieceJobInBackground(piece.id)
return toContentPieceViewModel(piece)
} catch (error) {
// A concurrent paste can still hit the unique index; it is the same
// duplicate the probe above answers, so it maps to the same message.
if (
error instanceof Error &&
/source_url|sourceUrl|duplicate key|unique/i.test(error.message)
) {
throw new Error(CONTENT_PIECE_LINK_DUPLICATE_MESSAGE)
}
throw error
}
}

View file

@ -0,0 +1,45 @@
import type { CollectionConfig } from 'payload'
import { CONTENT_MEDIA_SLUG } from '@/lib/contentPiece'
import { canReadContentPiece, canUpdateContentPiece } from '@/utilities/campaignAccess'
/**
* C211 — private upload collection of a content piece's media. Like
* `recordingMedia`/`reelMedia`, deliberately NOT `media`: that collection is
* anonymous-read by contract (public pages), while a piece file may only be
* read by the communication roles until it is published. The browser reaches
* the file through the authenticated
* `/campanha/comunicacao/conteudos/[id]/arquivo` route; the public Central
* (S27) owns its own serving surface for published pieces, over this same
* object — never a second copy.
*/
export const ContentMedia: CollectionConfig = {
slug: CONTENT_MEDIA_SLUG,
labels: {
singular: 'Mídia de peça',
plural: 'Mídias de peças',
},
admin: {
group: 'Comunicação',
description: 'Arquivos das peças da Central de Conteúdos. Só abrem com login da campanha.',
},
access: {
create: canUpdateContentPiece,
read: canReadContentPiece,
update: canUpdateContentPiece,
delete: canUpdateContentPiece,
},
fields: [
{
name: 'alt',
type: 'text',
label: 'Texto alternativo',
required: true,
admin: {
description: 'Descrição do arquivo para acessibilidade.',
},
},
],
upload: true,
}

View file

@ -0,0 +1,433 @@
import type {
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
CollectionConfig,
} from 'payload'
import {
CONTENT_MEDIA_SLUG,
CONTENT_PIECE_CURATED_FIELDS,
CONTENT_PIECE_DESCRIPTION_MAX_LENGTH,
CONTENT_PIECE_INSTITUTION_MAX_LENGTH,
CONTENT_PIECE_ORIGINS,
CONTENT_PIECE_PROCESSING_STATUSES,
CONTENT_PIECE_STATUSES,
CONTENT_PIECE_STEPS,
CONTENT_PIECE_TITLE_MAX_LENGTH,
CONTENT_PIECE_TYPES,
contentPieceCuratedFieldLabels,
contentPieceOriginLabels,
contentPieceProcessingStatusLabels,
contentPieceSearchText,
contentPieceSlugCandidates,
contentPieceStatusLabels,
contentPieceStepLabels,
contentPieceTypeLabels,
} from '@/lib/contentPiece'
import { SPEECH_TOPICS } from '@/lib/speechFacets'
import { payloadAdminOnly } from '@/utilities/access/shared'
import {
canCreateContentPiece,
canReadContentPiece,
canSetCampaignSystemField,
canUpdateContentPiece,
} from '@/utilities/campaignAccess'
import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields'
import { revalidateContentPiecesListing } from '@/utilities/documents'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
/**
* C211 — one campaign content piece ("peça") of the internal Central: the
* catalogued material (uploaded file or Instagram/YouTube link) that the public
* Central (S27) publishes. `processingStatus`/`step`/`error` are the honest
* states of the transcription/cataloguing pipeline; `status` is the editorial
* kill switch (rascunho → publicado) and never deletes the file. `media` is the
* private upload, served only through the authenticated route under
* `/campanha`; `curatedFields` records what the assessoria edited, so the
* pipeline never overwrites a human decision (D6).
*/
const TYPE_OPTIONS = CONTENT_PIECE_TYPES.map((value) => ({
value,
label: contentPieceTypeLabels[value],
}))
const STATUS_OPTIONS = CONTENT_PIECE_STATUSES.map((value) => ({
value,
label: contentPieceStatusLabels[value],
}))
const PROCESSING_STATUS_OPTIONS = CONTENT_PIECE_PROCESSING_STATUSES.map((value) => ({
value,
label: contentPieceProcessingStatusLabels[value],
}))
const STEP_OPTIONS = CONTENT_PIECE_STEPS.map((value) => ({
value,
label: contentPieceStepLabels[value],
}))
const ORIGIN_OPTIONS = CONTENT_PIECE_ORIGINS.map((value) => ({
value,
label: contentPieceOriginLabels[value],
}))
/**
* The one seam with the public Central (S27): every write of a piece — the
* pipeline's cataloguing, the ficha, the kill switch — busts the listing tag
* the public surface caches under. A collection hook and not a per-caller line,
* so a new write path cannot forget it.
*/
const revalidateContentPieceListing: CollectionAfterChangeHook = ({ doc }) => {
revalidateContentPiecesListing()
return doc
}
/** Stamps `publishedAt` on every transition into `publicado` (create included). */
const stampContentPiecePublishedAt: CollectionBeforeChangeHook = ({
data,
operation,
originalDoc,
}) => {
if (
data.status === 'publicado' &&
(operation === 'create' || originalDoc?.status !== 'publicado')
) {
data.publishedAt = new Date().toISOString()
}
return data
}
/**
* D7 — the public slug is generated on the FIRST transition into `publicado`,
* from the title in force, and never changes after that: a draft has no slug,
* and unpublishing preserves it (the link already shared keeps working). The
* probe runs under a text advisory lock so two pieces published with the same
* title cannot race the unique constraint; the candidates add `-2`, `-3`…
* inside the caller's transaction (the publish action always provides one).
*/
const setCanonicalContentPieceSlug: CollectionBeforeChangeHook = async ({
data,
operation,
originalDoc,
req,
}) => {
if (!data) return data
const enteringPublished =
data.status === 'publicado' && (operation === 'create' || originalDoc?.status !== 'publicado')
if (!enteringPublished || originalDoc?.slug) return data
const title = data.title ?? originalDoc?.title ?? ''
const candidates = contentPieceSlugCandidates(title)
await acquireTextAdvisoryLocks(req.payload, req, [`content-piece-slug:${candidates[0]}`])
// Intentional admin bypass: the slug probe must see every piece, including
// drafts the publishing actor cannot read.
const existing = await req.payload.find({
collection: 'contentPiece',
where: { slug: { in: candidates } },
depth: 0,
limit: 0,
pagination: false,
select: { slug: true },
overrideAccess: true,
req,
})
const taken = new Set(existing.docs.map((doc) => doc.slug))
const free = candidates.find((candidate) => !taken.has(candidate))
if (free) data.slug = free
return data
}
/**
* Denormalized catalogue index: `cityLabel`/`region` mirror the related
* município (a static, read-only catalog) and `searchText` is the normalized
* haystack the list search matches — the same shape C199 persists on a
* recording. The município is only read when the relation was TOUCHED: every
* partial update (status, step, error, the job's heartbeats) reuses the stored
* `cityLabel`/`region` instead of paying a query per save.
*/
const deriveContentPieceCatalogIndex: CollectionBeforeValidateHook = async ({
data,
originalDoc,
req,
}) => {
if (!data) return data
const relationTouched = data.municipality !== undefined
const municipalityValue = relationTouched ? data.municipality : originalDoc?.municipality
const municipalityId =
typeof municipalityValue === 'number'
? municipalityValue
: typeof municipalityValue === 'object' && municipalityValue !== null
? municipalityValue.id
: null
let cityLabel = originalDoc?.cityLabel ?? null
let region = originalDoc?.region ?? null
if (relationTouched) {
cityLabel = null
region = null
if (municipalityId !== null) {
const municipality = await req.payload
.findByID({
collection: 'municipality',
id: municipalityId,
depth: 0,
select: { name: true, region: true },
// Intentional admin bypass: the município catalog is read-only geography.
overrideAccess: true,
req,
})
.catch(() => null)
cityLabel = municipality?.name ?? null
region = municipality?.region ?? null
}
}
data.cityLabel = cityLabel
data.region = region
data.searchText = contentPieceSearchText({
title: data.title ?? originalDoc?.title,
description: data.description ?? originalDoc?.description,
transcript: data.transcript ?? originalDoc?.transcript,
institution: data.institution ?? originalDoc?.institution,
topics: data.topics ?? originalDoc?.topics,
cityLabel,
})
return data
}
export const ContentPiece: CollectionConfig = {
slug: 'contentPiece',
labels: {
singular: 'Peça',
plural: 'Peças',
},
admin: {
group: 'Comunicação',
useAsTitle: 'title',
defaultColumns: ['title', 'type', 'processingStatus', 'status', 'pieceDate'],
description:
'Peças de campanha da Central de Conteúdos. O arquivo é privado; "Rascunho" não aparece na Central pública.',
},
access: {
create: canCreateContentPiece,
read: canReadContentPiece,
update: canUpdateContentPiece,
// C211 has no delete surface in the Central: only the Payload admin removes
// a row (the S27/support path), so campaign roles never lose a file by
// accident.
delete: payloadAdminOnly,
},
hooks: {
beforeValidate: [deriveContentPieceCatalogIndex],
beforeChange: [
setCanonicalContentPieceSlug,
stampContentPiecePublishedAt,
stampCampaignCreatedBy,
],
afterChange: [revalidateContentPieceListing],
},
fields: [
{
name: 'title',
type: 'text',
label: 'Título',
required: true,
maxLength: CONTENT_PIECE_TITLE_MAX_LENGTH,
},
{
name: 'slug',
type: 'text',
label: 'Endereço na Central pública',
unique: true,
index: true,
admin: {
readOnly: true,
description:
'Gerado no primeiro "Publicar" a partir do título e imutável depois. Despublicar não apaga o endereço.',
},
},
{
name: 'type',
type: 'select',
label: 'Tipo',
required: true,
index: true,
options: TYPE_OPTIONS,
admin: {
description: 'Derivado do arquivo no envio; a assessoria pode ajustar (ex.: card).',
},
},
{
name: 'description',
type: 'textarea',
label: 'Descrição',
maxLength: CONTENT_PIECE_DESCRIPTION_MAX_LENGTH,
},
{
name: 'topics',
type: 'select',
label: 'Temas',
hasMany: true,
index: true,
options: SPEECH_TOPICS.map((topic) => ({ value: topic.value, label: topic.label })),
},
{
name: 'municipality',
type: 'relationship',
relationTo: 'municipality',
label: 'Cidade',
index: true,
admin: {
description: 'Município da campanha relacionado à peça (opcional).',
},
},
{
name: 'cityLabel',
type: 'text',
label: 'Cidade (busca)',
admin: {
readOnly: true,
description: 'Derivado do município relacionado.',
},
},
{
name: 'region',
type: 'text',
label: 'Região',
admin: {
readOnly: true,
description: 'Território de identidade derivado do município.',
},
},
{
name: 'institution',
type: 'text',
label: 'Instituição',
maxLength: CONTENT_PIECE_INSTITUTION_MAX_LENGTH,
},
{
name: 'pieceDate',
type: 'date',
label: 'Data da peça',
index: true,
},
{
name: 'durationSeconds',
type: 'number',
label: 'Duração (s)',
admin: {
readOnly: true,
description: 'Medida pelo provedor de transcrição; nunca estimada.',
},
},
{
name: 'transcript',
type: 'textarea',
label: 'Transcrição / texto',
admin: {
description: 'Transcrição do áudio/vídeo ou texto extraído. Editável pela assessoria.',
},
},
{
name: 'media',
type: 'upload',
relationTo: CONTENT_MEDIA_SLUG,
label: 'Arquivo da peça',
admin: {
readOnly: true,
description: 'O arquivo privado; publicado, é o que a Central pública serve.',
},
},
{
name: 'sourceUrl',
type: 'text',
label: 'Link de origem',
unique: true,
index: true,
admin: {
readOnly: true,
description: 'Link canônico do Instagram/YouTube quando a peça entrou por link.',
},
},
{
name: 'origin',
type: 'select',
label: 'Origem',
required: true,
defaultValue: 'arquivo',
options: ORIGIN_OPTIONS,
},
{
name: 'status',
type: 'select',
label: 'Publicação',
required: true,
defaultValue: 'rascunho',
index: true,
options: STATUS_OPTIONS,
admin: {
description: 'Despublicar tira a peça da Central pública na hora e preserva o arquivo.',
},
},
{
name: 'publishedAt',
type: 'date',
label: 'Publicado em',
admin: { readOnly: true },
},
{
name: 'processingStatus',
type: 'select',
label: 'Processamento',
required: true,
defaultValue: 'pronto',
index: true,
options: PROCESSING_STATUS_OPTIONS,
},
{
name: 'step',
type: 'select',
label: 'Passo',
options: STEP_OPTIONS,
admin: {
readOnly: true,
description: 'Progresso honesto do processamento em andamento.',
},
},
{
name: 'error',
type: 'textarea',
label: 'Erro',
admin: {
readOnly: true,
description: 'Motivo interno da falha; nunca vai à pessoa com o detalhe cru.',
},
},
{
name: 'searchText',
type: 'textarea',
label: 'Texto normalizado (busca)',
admin: {
readOnly: true,
description:
'Título, descrição, transcrição, temas, cidade e instituição normalizados (sem acentos, minúsculas).',
},
},
{
name: 'curatedFields',
type: 'select',
label: 'Campos curados pela assessoria',
hasMany: true,
options: CONTENT_PIECE_CURATED_FIELDS.map((value) => ({
value,
label: contentPieceCuratedFieldLabels[value],
})),
admin: {
readOnly: true,
description:
'O que a assessoria já editou; a catalogação automática nunca sobrescreve estes campos.',
},
},
systemStampedActorField({ setAccess: canSetCampaignSystemField }),
],
}

View file

@ -0,0 +1,179 @@
'use client'
import { CircleAlertIcon, LinkIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState, type ReactNode } from 'react'
import type { ContentPieceLinkResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/types'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Spinner } from '@/components/ui/Spinner'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { CAMPAIGN_CONTENT_PIECE_LINK_HREF } from '@/lib/campaignPaths'
import { CONTENT_PIECE_LINK_INVALID_MESSAGE } from '@/lib/schemas/contentPiece'
import { cn } from '@/lib/utils'
const GENERIC_ERROR_MESSAGE = 'Não foi possível adicionar a peça. Tente novamente.'
/**
* C211 — "Adicionar por link" (approved design scene 3): the assessoria pastes
* an Instagram/YouTube publication and the piece enters the same catalogue. The
* panel states the product rule up front — no scraping; when the platform
* offers no official path the piece circulates by link and the original can be
* attached.
*/
export const AddContentPieceLinkDialog = ({
triggerClassName,
triggerLabel = 'Adicionar por link',
}: {
triggerClassName?: string
/** A node so the page can shorten the label on mobile ("Link"). */
triggerLabel?: ReactNode
}) => {
const router = useRouter()
const [open, setOpen] = useState(false)
const [url, setUrl] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const submit = async () => {
const trimmed = url.trim()
if (!trimmed) {
setError(CONTENT_PIECE_LINK_INVALID_MESSAGE)
return
}
setSubmitting(true)
setError(null)
try {
const { ok, payload } = await postCampaignJson<ContentPieceLinkResponse>(
CAMPAIGN_CONTENT_PIECE_LINK_HREF,
{ url: trimmed },
)
if (!ok || payload.status !== 'success') {
setError(payload.status === 'error' ? payload.message : GENERIC_ERROR_MESSAGE)
return
}
setUrl('')
setOpen(false)
router.refresh()
} catch {
setError(GENERIC_ERROR_MESSAGE)
} finally {
setSubmitting(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (submitting) return
setOpen(next)
if (!next) {
setUrl('')
setError(null)
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" className={cn('min-h-11', triggerClassName)}>
<LinkIcon data-icon="inline-start" aria-hidden="true" />
{triggerLabel}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="border-b-0 pb-0">Adicionar por link</DialogTitle>
<DialogDescription>
Instagram ou YouTube. Conteúdo próprio é extraído somente quando a plataforma oferece
caminho oficial.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div>
<Label htmlFor="content-piece-link">Link da publicação</Label>
<Input
id="content-piece-link"
value={url}
onChange={(event) => {
setUrl(event.target.value)
if (error) setError(null)
}}
placeholder="https://www.instagram.com/reel/…"
className="mt-1.5 min-h-11"
aria-invalid={error ? true : undefined}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
}
}}
/>
</div>
<div className="rounded-lg border bg-white p-4 text-xs leading-5 text-muted-foreground">
<b className="text-foreground">Sem scraping.</b>
<br />
Se não houver acesso oficial à mídia, a peça continua pelo link e o arquivo original
pode ser anexado.
</div>
<ul className="space-y-2 text-xs leading-5 text-muted-foreground">
<li>
<b className="text-foreground">Mídia encontrada na conta oficial:</b> o arquivo entra
na peça e passa pelo mesmo processamento do envio.
</li>
<li>
<b className="text-foreground">Sem caminho oficial:</b> a peça nasce como peça-link e
a assessoria pode anexar o arquivo original. Mídia de terceiro nunca é baixada.
</li>
</ul>
{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)
setUrl('')
setError(null)
}}
>
Cancelar
</Button>
<Button
type="button"
className="min-h-11"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{submitting ? 'Adicionando…' : 'Adicionar e catalogar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,106 @@
'use client'
import { CircleAlertIcon, PaperclipIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useRef, useState, type ChangeEvent } from 'react'
import type { ContentPieceAttachResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/types'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { campaignContentPieceFileHref } from '@/lib/campaignPaths'
import {
CONTENT_PIECE_ACCEPT,
CONTENT_PIECE_MAX_BYTES,
contentPieceTooLargeMessage,
} from '@/lib/contentPiece'
const GENERIC_ERROR_MESSAGE = 'Não foi possível anexar o arquivo. Tente novamente.'
/**
* C211 — "Anexar arquivo original" of a peça-link (approved design scene 3): the
* assessoria supplies the file when the platform offered no official path, and
* the piece runs the same processing as an upload. Raw body via XHR, like the
* batch dialog — the same route that serves the file accepts the POST.
*/
export const ContentPieceAttachFileButton = ({
contentPieceId,
className,
}: {
contentPieceId: number
className?: string
}) => {
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const onFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null
if (inputRef.current) inputRef.current.value = ''
if (!file) return
if (file.size > CONTENT_PIECE_MAX_BYTES) {
setError(contentPieceTooLargeMessage(file.size))
return
}
const params = new URLSearchParams({ filename: file.name })
const xhr = new XMLHttpRequest()
setSubmitting(true)
setError(null)
xhr.open('POST', `${campaignContentPieceFileHref(contentPieceId)}?${params.toString()}`)
xhr.setRequestHeader('Accept', 'application/json')
xhr.addEventListener('load', () => {
setSubmitting(false)
try {
const payload = JSON.parse(xhr.responseText) as ContentPieceAttachResponse
if (xhr.status >= 200 && xhr.status < 300 && payload.status === 'success') {
router.refresh()
return
}
setError(payload.status === 'error' ? payload.message : GENERIC_ERROR_MESSAGE)
} catch {
setError(GENERIC_ERROR_MESSAGE)
}
})
xhr.addEventListener('error', () => {
setSubmitting(false)
setError(GENERIC_ERROR_MESSAGE)
})
xhr.send(file)
}
return (
<div className={className}>
<input
ref={inputRef}
type="file"
accept={CONTENT_PIECE_ACCEPT}
className="sr-only"
onChange={onFileChange}
/>
<Button
type="button"
variant="outline"
className="min-h-11"
disabled={submitting}
onClick={() => inputRef.current?.click()}
>
{submitting ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<PaperclipIcon data-icon="inline-start" aria-hidden="true" />
)}
{submitting ? 'Anexando…' : 'Anexar arquivo original'}
</Button>
{error ? (
<Alert variant="destructive" className="mt-2 py-2">
<CircleAlertIcon aria-hidden="true" />
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
</div>
)
}

View file

@ -0,0 +1,64 @@
import Link from 'next/link'
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
import {
ContentPieceProcessingBadge,
ContentPiecePublicationBadge,
} from '@/components/campaign/content/ContentPieceStatusBadge'
import { Button } from '@/components/ui/button'
import { contentPieceStepLabels, type ContentPieceViewModel } from '@/lib/contentPiece'
/**
* C211 — the Central list on mobile (approved design scenes 5): one card per
* piece with the title + processing state, the type/publication line and the
* single "Abrir" (or "Reprocessar" when it failed).
*/
export const ContentPieceCardList = ({
rows,
empty,
}: {
rows: readonly ContentPieceViewModel[]
empty?: React.ReactNode
}) => (
<div className="flex flex-col gap-3 md:hidden">
{rows.length === 0 && empty ? empty : null}
{rows.map((piece) => (
<article key={piece.id} className="rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<Link href={piece.detailHref} className="min-w-0 text-sm font-semibold hover:underline">
{piece.title}
</Link>
<ContentPieceProcessingBadge status={piece.processingStatus} className="shrink-0" />
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ContentPiecePublicationBadge status={piece.status} />
<span>
{[piece.typeLabel, piece.durationLabel].filter(Boolean).join(' · ')}
{piece.cityLabel ? ` · ${piece.cityLabel}` : ''}
</span>
</div>
{piece.processingStatus === 'processando' ? (
<>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full w-1/2 animate-pulse rounded-full bg-primary motion-reduce:animate-none" />
</div>
<p className="mt-2 text-xs text-muted-foreground">
{piece.step ? contentPieceStepLabels[piece.step] : 'Processando…'}
</p>
</>
) : piece.processingStatus === 'falhou' && piece.failureMessage ? (
<p className="mt-2 text-xs text-red-800">{piece.failureMessage}</p>
) : null}
{piece.canRetry ? (
<ContentPieceRetryButton contentPieceId={piece.id} className="mt-3" />
) : (
<Button asChild variant="outline" className="mt-3 min-h-11 w-full">
<Link href={piece.detailHref}>Abrir</Link>
</Button>
)}
</article>
))}
</div>
)

View file

@ -0,0 +1,214 @@
'use client'
import { useOptimistic, useState, type ReactNode } from 'react'
import {
CampaignHeaderFilterPopover,
type CampaignHeaderFilterRow,
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
import {
CampaignListOmnibox,
campaignListOmniboxFormClassName,
} from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import {
CONTENT_PIECE_PROCESSING_STATUSES,
CONTENT_PIECE_STATUSES,
CONTENT_PIECE_TYPES,
contentPieceProcessingStatusLabels,
contentPieceStatusLabels,
contentPieceTypeLabels,
isContentPieceProcessingStatus,
isContentPieceStatus,
isContentPieceType,
} from '@/lib/contentPiece'
import {
buildContentPieceListHref,
clearContentPieceListFilters,
toggleContentPieceProcessing,
toggleContentPieceStatus,
toggleContentPieceType,
type ContentPieceListState,
} from '@/utilities/content/contentPieceListUrl'
type FacetKey = 'types' | 'statuses' | 'processing'
const FACETS = [
{
key: 'types',
id: 'content-piece-filter-type',
label: 'Tipo',
options: CONTENT_PIECE_TYPES.map((value) => ({ value, label: contentPieceTypeLabels[value] })),
toggle: toggleContentPieceType,
},
{
key: 'statuses',
id: 'content-piece-filter-status',
label: 'Publicação',
options: CONTENT_PIECE_STATUSES.map((value) => ({
value,
label: contentPieceStatusLabels[value],
})),
toggle: toggleContentPieceStatus,
},
{
key: 'processing',
id: 'content-piece-filter-processing',
label: 'Processamento',
options: CONTENT_PIECE_PROCESSING_STATUSES.map((value) => ({
value,
label: contentPieceProcessingStatusLabels[value],
})),
toggle: toggleContentPieceProcessing,
},
] as const
const chipLabelFor = (state: ContentPieceListState): { id: string; label: string }[] => {
const chips: { id: string; label: string }[] = []
if (state.q) chips.push({ id: 'q', label: `Busca: ${state.q}` })
for (const value of state.types ?? []) {
chips.push({ id: `types:${value}`, label: `Tipo: ${contentPieceTypeLabels[value]}` })
}
for (const value of state.statuses ?? []) {
chips.push({
id: `statuses:${value}`,
label: `Publicação: ${contentPieceStatusLabels[value]}`,
})
}
for (const value of state.processing ?? []) {
chips.push({
id: `processing:${value}`,
label: `Processamento: ${contentPieceProcessingStatusLabels[value]}`,
})
}
return chips
}
const removeChip = (state: ContentPieceListState, chipId: string): ContentPieceListState => {
if (chipId === 'q') return { ...state, page: 1, q: undefined }
const [facet, value] = chipId.split(':') as [FacetKey, string]
if (facet === 'types' && isContentPieceType(value)) return toggleContentPieceType(state, value)
if (facet === 'statuses' && isContentPieceStatus(value)) {
return toggleContentPieceStatus(state, value)
}
if (facet === 'processing' && isContentPieceProcessingStatus(value)) {
return toggleContentPieceProcessing(state, value)
}
return state
}
const selectedCount = (state: ContentPieceListState): number =>
(state.types?.length ?? 0) + (state.statuses?.length ?? 0) + (state.processing?.length ?? 0)
/**
* C211 — the search bar and the single "Filtros" trigger of the Central list
* (approved design scene 1): the three facets are groups inside one popover,
* each a multi-select over the shared row primitive.
*/
export const ContentPieceFilters = ({
state,
trailing,
}: {
state: ContentPieceListState
/** The column picker (B17/B137) mounted in the omnibox row. */
trailing?: ReactNode
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => buildContentPieceListHref(next, 1),
})
const [viewState, setOptimisticState] = useOptimistic(state)
// The field is a draft: the committed term lives in the URL and shows as a chip.
const [draftQuery, setDraftQuery] = useState('')
const runState = (next: ContentPieceListState) => {
setOptimisticState(next)
navigate(next)
}
const sections = FACETS.map((facet) => {
const selected = (viewState[facet.key] ?? []) as readonly string[]
return {
label: facet.label,
rows: facet.options.map((option) => {
const next = (
facet.toggle as (state: ContentPieceListState, value: string) => ContentPieceListState
)(viewState, option.value)
return {
value: option.value,
label: option.label,
href: buildContentPieceListHref(next, 1),
selected: selected.includes(option.value),
checkbox: true,
onChoose: () => setOptimisticState(next),
} satisfies CampaignHeaderFilterRow
}),
}
})
const active = selectedCount(viewState)
return (
<form
role="search"
className={campaignListOmniboxFormClassName}
onSubmit={(event) => {
event.preventDefault()
const term = draftQuery.trim()
setDraftQuery('')
runState({ ...viewState, page: 1, q: term || undefined })
}}
>
<CampaignListOmnibox
label="Buscar conteúdos"
placeholder="Buscar por título, tema ou cidade…"
chips={chipLabelFor(viewState)}
suggestions={[]}
query={draftQuery}
onQueryChange={setDraftQuery}
onSelectSuggestion={() => undefined}
onRemoveChip={(chipId) => runState(removeChip({ ...viewState, page: 1 }, chipId))}
onCommitQuery={(text) => {
const term = text.trim()
setDraftQuery('')
runState({ ...viewState, page: 1, q: term || undefined })
}}
onClearAll={() => {
setDraftQuery('')
runState(clearContentPieceListFilters())
}}
isPending={isPending}
// The omnibox owns the desktop trailing cluster (mobile filters live in
// the list header, per the approved design): the "Filtros" trigger and
// the column picker ride it together.
trailing={
<>
<div role="group" aria-label="Filtrar conteúdos">
<CampaignHeaderFilterPopover
id="content-piece-filters"
label="Filtros"
triggerVariant="chip"
triggerLabel={active > 0 ? `Filtros: ${active}` : 'Filtros'}
active={active > 0}
closeOnChoose={false}
emptyLabel="Nenhuma peça neste recorte."
optionRows={[]}
sections={sections}
clear={
active > 0
? {
href: buildContentPieceListHref(clearContentPieceListFilters(), 1),
onChoose: () => setOptimisticState(clearContentPieceListFilters()),
}
: undefined
}
/>
</div>
{trailing}
</>
}
/>
</form>
)
}

View file

@ -0,0 +1,212 @@
'use client'
import { startTransition, useActionState, useState, type FormEvent } from 'react'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import {
CONTENT_PIECE_DESCRIPTION_MAX_LENGTH,
CONTENT_PIECE_INSTITUTION_MAX_LENGTH,
CONTENT_PIECE_TITLE_MAX_LENGTH,
CONTENT_PIECE_TYPES,
contentPieceTypeLabels,
type ContentPieceType,
} from '@/lib/contentPiece'
import { SPEECH_TOPICS, type SpeechTopic } from '@/lib/speechFacets'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
type MunicipalityOption = { id: number; name: string }
/**
* C211 — the editable ficha of one piece (approved design scene 4): the
* catalogue the pipeline filled, ready for the assessoria to revise. Saving
* marks the sent fields as curated, so the automatic cataloguing never
* overwrites them (D6). The transcript is a first-class field — it is what the
* public search (S28) indexes.
*/
export const ContentPieceForm = ({
piece,
municipalityOptions,
formAction,
}: {
piece: {
id: number
title: string
description: string | null
type: ContentPieceType
pieceDate: string | null
topics: SpeechTopic[]
municipalityId: number | null
institution: string | null
transcript: string | null
}
municipalityOptions: readonly MunicipalityOption[]
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
const [topics, setTopics] = useState<SpeechTopic[]>(piece.topics)
const [type, setType] = useState<ContentPieceType>(piece.type)
// C140 — manual dispatch (no `action={submitAction}`): React 19 resets
// uncontrolled fields after any settled form action, wiping typed values on a
// validation error — the page stays visible, so the wipe showed.
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
startTransition(() => submitAction(new FormData(event.currentTarget)))
}
const toggleTopic = (topic: SpeechTopic) => {
setTopics((current) =>
current.includes(topic) ? current.filter((value) => value !== topic) : [...current, topic],
)
}
return (
<form onSubmit={handleSubmit} className="grid max-w-3xl grid-cols-1 gap-4 sm:grid-cols-2">
<input type="hidden" name="contentPieceId" value={piece.id} />
{topics.map((topic) => (
<input key={topic} type="hidden" name="topics" value={topic} />
))}
<Field className="sm:col-span-2">
<FieldLabel htmlFor="content-piece-title">Título</FieldLabel>
<Input
id="content-piece-title"
name="title"
required
maxLength={CONTENT_PIECE_TITLE_MAX_LENGTH}
defaultValue={piece.title}
className="min-h-11"
/>
{fieldError(state.fieldErrors, 'title') ? (
<FieldError>{fieldError(state.fieldErrors, 'title')}</FieldError>
) : null}
</Field>
<Field className="sm:col-span-2">
<FieldLabel htmlFor="content-piece-description">Descrição</FieldLabel>
<Textarea
id="content-piece-description"
name="description"
rows={3}
maxLength={CONTENT_PIECE_DESCRIPTION_MAX_LENGTH}
defaultValue={piece.description ?? undefined}
/>
</Field>
<Field>
<FieldLabel htmlFor="content-piece-type">Tipo</FieldLabel>
<NativeSelect
id="content-piece-type"
name="type"
value={type}
onChange={(event) => setType(event.target.value as ContentPieceType)}
className="min-h-11 w-full"
>
{CONTENT_PIECE_TYPES.map((value) => (
<NativeSelectOption key={value} value={value}>
{contentPieceTypeLabels[value]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="content-piece-date">Data da peça</FieldLabel>
<Input
id="content-piece-date"
name="pieceDate"
type="date"
defaultValue={piece.pieceDate?.slice(0, 10) ?? undefined}
className="min-h-11"
/>
</Field>
<Field className="sm:col-span-2">
<FieldLabel>Temas</FieldLabel>
<div role="group" aria-label="Temas" className="flex flex-wrap gap-2">
{SPEECH_TOPICS.map((topic) => {
const active = topics.includes(topic.value)
return (
<button
key={topic.value}
type="button"
aria-pressed={active}
onClick={() => toggleTopic(topic.value)}
className={cn(
'min-h-11 rounded-full border px-3 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:min-h-9',
active
? 'border-foreground/25 bg-muted font-semibold text-foreground'
: 'border-border font-normal text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{topic.label}
</button>
)
})}
</div>
</Field>
<Field>
<FieldLabel htmlFor="content-piece-municipality">Cidade</FieldLabel>
<NativeSelect
id="content-piece-municipality"
name="municipalityId"
defaultValue={piece.municipalityId ? String(piece.municipalityId) : ''}
className="min-h-11 w-full"
>
<NativeSelectOption value="">Sem cidade</NativeSelectOption>
{municipalityOptions.map((municipality) => (
<NativeSelectOption key={municipality.id} value={String(municipality.id)}>
{municipality.name}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="content-piece-institution">Instituição</FieldLabel>
<Input
id="content-piece-institution"
name="institution"
maxLength={CONTENT_PIECE_INSTITUTION_MAX_LENGTH}
defaultValue={piece.institution ?? undefined}
className="min-h-11"
/>
</Field>
<Field className="sm:col-span-2">
<FieldLabel htmlFor="content-piece-transcript">Transcrição / texto</FieldLabel>
<Textarea
id="content-piece-transcript"
name="transcript"
rows={8}
defaultValue={piece.transcript ?? undefined}
/>
</Field>
<div className="sm:col-span-2">
<CampaignFormActionMessage state={state} />
</div>
<Button
type="submit"
disabled={isPending}
className="min-h-11 sm:col-span-2 sm:justify-self-end"
>
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar alterações
</Button>
</form>
)
}

View file

@ -0,0 +1,84 @@
'use client'
import { CircleAlertIcon, EyeOffIcon, SendIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import type { ContentPiecePublicationResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/types'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { campaignContentPiecePublicationHref } from '@/lib/campaignPaths'
import type { ContentPieceStatus } from '@/lib/contentPiece'
const PUBLICATION_ERROR_MESSAGE = 'Não foi possível mudar a publicação. Tente novamente.'
/**
* C211 — the kill switch of one piece: "Publicar" (the first publication also
* generates the canonical slug) and "Despublicar" (takes it off the public
* Central at once, preserving the file and the link). Never a confirm dialog:
* the gesture is reversible and the badge on the screen is the receipt.
*/
export const ContentPiecePublicationButton = ({
contentPieceId,
status,
className,
}: {
contentPieceId: number
status: ContentPieceStatus
className?: string
}) => {
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const published = status === 'publicado'
const toggle = async () => {
setSubmitting(true)
setError(null)
try {
const { ok, payload } = await postCampaignJson<ContentPiecePublicationResponse>(
campaignContentPiecePublicationHref(contentPieceId),
{ contentPieceId, published: !published },
)
if (!ok || payload.status !== 'success') {
setError(PUBLICATION_ERROR_MESSAGE)
return
}
router.refresh()
} catch {
setError(PUBLICATION_ERROR_MESSAGE)
} finally {
setSubmitting(false)
}
}
return (
<div className={className}>
<Button
type="button"
variant={published ? 'outline' : 'default'}
className="min-h-11 max-md:w-full"
disabled={submitting}
onClick={() => void toggle()}
>
{submitting ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : published ? (
<EyeOffIcon data-icon="inline-start" aria-hidden="true" />
) : (
<SendIcon data-icon="inline-start" aria-hidden="true" />
)}
{submitting ? 'Salvando…' : published ? 'Despublicar' : 'Publicar'}
</Button>
{error ? (
<Alert variant="destructive" className="mt-2 py-2">
<CircleAlertIcon aria-hidden="true" />
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
</div>
)
}

View file

@ -0,0 +1,88 @@
'use client'
import { CircleAlertIcon, RotateCcwIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import type { ContentPieceRetryResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/types'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { campaignContentPieceRetryHref } from '@/lib/campaignPaths'
const RETRY_ERROR_MESSAGE = 'Não foi possível iniciar o reprocessamento. Tente novamente.'
/**
* C211 — reprocesses a failed piece in place: the list action and the ficha's
* failure panel share this one control (approved design scenes 1 and 4). The
* error of the click stays as a bordered alert under the action, never a toast.
*/
export const ContentPieceRetryButton = ({
contentPieceId,
className,
label = 'Reprocessar',
variant = 'outline',
onSubmittingChange,
}: {
contentPieceId: number
className?: string
label?: string
variant?: 'default' | 'outline'
onSubmittingChange?: (submitting: boolean) => void
}) => {
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const updateSubmitting = (value: boolean) => {
setSubmitting(value)
onSubmittingChange?.(value)
}
const retry = async () => {
updateSubmitting(true)
setError(null)
try {
const { ok, payload } = await postCampaignJson<ContentPieceRetryResponse>(
campaignContentPieceRetryHref(contentPieceId),
{ contentPieceId },
)
if (!ok || payload.status !== 'success') {
setError(RETRY_ERROR_MESSAGE)
return
}
router.refresh()
} catch {
setError(RETRY_ERROR_MESSAGE)
} finally {
updateSubmitting(false)
}
}
return (
<div className={className}>
<Button
type="button"
variant={variant}
className="min-h-11 max-md:w-full"
disabled={submitting}
onClick={() => void retry()}
>
{submitting ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<RotateCcwIcon data-icon="inline-start" aria-hidden="true" />
)}
{submitting ? 'Reprocessando…' : label}
</Button>
{error ? (
<Alert variant="destructive" className="mt-2 py-2">
<CircleAlertIcon aria-hidden="true" />
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
</div>
)
}

View file

@ -0,0 +1,56 @@
import {
contentPieceProcessingStatusLabels,
contentPieceStatusLabels,
type ContentPieceProcessingStatus,
type ContentPieceStatus,
} from '@/lib/contentPiece'
import { cn } from '@/lib/utils'
const PROCESSING_TONE: Record<ContentPieceProcessingStatus, string> = {
processando: 'bg-amber-100 text-amber-900',
pronto: 'bg-green-100 text-green-800',
falhou: 'bg-red-100 text-red-700',
}
const PUBLICATION_TONE: Record<ContentPieceStatus, string> = {
rascunho: 'bg-muted text-muted-foreground',
publicado: 'bg-rose-50 text-rose-900 ring-1 ring-rose-200',
}
/** C211 — the one processing chip of the Central surfaces (list and ficha). */
export const ContentPieceProcessingBadge = ({
status,
className,
}: {
status: ContentPieceProcessingStatus
className?: string
}) => (
<span
className={cn(
'inline-flex min-h-5 items-center rounded-full px-2 py-0.5 text-xs font-semibold',
PROCESSING_TONE[status],
className,
)}
>
{contentPieceProcessingStatusLabels[status]}
</span>
)
/** C211 — the publication chip: "Rascunho" is off the public Central. */
export const ContentPiecePublicationBadge = ({
status,
className,
}: {
status: ContentPieceStatus
className?: string
}) => (
<span
className={cn(
'inline-flex min-h-5 items-center rounded-full px-2 py-0.5 text-xs font-semibold',
PUBLICATION_TONE[status],
className,
)}
>
{contentPieceStatusLabels[status]}
</span>
)

View file

@ -0,0 +1,62 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import type { ContentPieceStatusResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/types'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { CAMPAIGN_CONTENT_PIECE_STATUS_HREF } from '@/lib/campaignPaths'
/**
* C211 — keeps the honest processing states moving without a full reload: while
* a visible piece is `processando`, polls the status route and refreshes the
* server tree when any state changed. Silent on failure — the next tick
* retries; pauses on a hidden tab.
*/
export const ContentPieceStatusRefresher = ({
pieces,
intervalMs = 5_000,
}: {
pieces: readonly { id: number; processingStatus: string }[]
intervalMs?: number
}) => {
const router = useRouter()
const pending = pieces.some((piece) => piece.processingStatus === 'processando')
const signature = pieces
.map((piece) => `${piece.id}:${piece.processingStatus}`)
.sort()
.join(',')
useEffect(() => {
// Terminal states never poll: the timer only exists while something moves.
if (!signature || !pending) return
const ids = signature.split(',').map((entry) => Number(entry.split(':')[0]))
let stopped = false
const tick = async () => {
if (stopped || document.visibilityState === 'hidden') return
try {
const { ok, payload } = await postCampaignJson<ContentPieceStatusResponse>(
CAMPAIGN_CONTENT_PIECE_STATUS_HREF,
{ contentPieceIds: ids },
)
if (stopped || !ok || payload.status !== 'success') return
const nextSignature = payload.pieces
.map((piece) => `${piece.id}:${piece.processingStatus}`)
.sort()
.join(',')
if (nextSignature !== signature) router.refresh()
} catch {
// A failed poll is a lost tick, never a user-facing error.
}
}
const timer = window.setInterval(() => void tick(), intervalMs)
return () => {
stopped = true
window.clearInterval(timer)
}
}, [signature, pending, intervalMs, router])
return null
}

View file

@ -0,0 +1,123 @@
import { FilmIcon, ImageIcon, LinkIcon, MicIcon, TypeIcon } from 'lucide-react'
import Link from 'next/link'
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
import {
ContentPieceProcessingBadge,
ContentPiecePublicationBadge,
} from '@/components/campaign/content/ContentPieceStatusBadge'
import { CampaignTable, CampaignTableHead } from '@/components/campaign/shared/CampaignTable'
import { Button } from '@/components/ui/button'
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
import {
contentPieceStepLabels,
type ContentPieceType,
type ContentPieceViewModel,
} from '@/lib/contentPiece'
const TYPE_ICON: Record<ContentPieceType, typeof FilmIcon> = {
video: FilmIcon,
foto: ImageIcon,
texto: TypeIcon,
audio: MicIcon,
card: ImageIcon,
}
const metaLine = (piece: ContentPieceViewModel): string =>
[piece.typeLabel, piece.cityLabel ?? piece.regionLabel, piece.durationLabel]
.filter((value): value is string => Boolean(value))
.join(' · ')
/**
* C211 — one piece of the Central list (approved design scene 1): thumbnail,
* title and the type/city/duration line, the honest processing state, the
* publication state and one action ("Abrir" or "Reprocessar" when it failed).
*/
export const ContentPieceTable = ({
rows,
columnVisibility,
empty,
}: {
rows: readonly ContentPieceViewModel[]
columnVisibility?: CampaignColumnVisibility
empty?: React.ReactNode
}) => (
<CampaignTable
className="hidden md:block"
caption="Uma linha por peça da Central de Conteúdos. Rascunhos não aparecem na Central pública."
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={empty}
columns={[
{
id: 'piece',
label: 'Peça',
mandatory: true,
head: <CampaignTableHead>Peça</CampaignTableHead>,
cell: (piece) => {
const Icon = TYPE_ICON[piece.type]
return (
<div className="flex items-center gap-3">
<span className="grid h-14 w-20 shrink-0 place-items-center rounded-md bg-gradient-to-br from-stone-200 to-stone-100 text-stone-500">
<Icon className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0">
<Link href={piece.detailHref} className="font-semibold hover:underline">
{piece.title}
</Link>
<p className="mt-1 truncate text-xs text-muted-foreground">{metaLine(piece)}</p>
{piece.origin !== 'arquivo' ? (
<p className="mt-0.5 inline-flex items-center gap-1 text-xs text-muted-foreground">
<LinkIcon className="size-3" aria-hidden="true" />
{piece.originLabel}
</p>
) : null}
</div>
</div>
)
},
},
{
id: 'processing',
label: 'Processamento',
cell: (piece) => (
<div>
<ContentPieceProcessingBadge status={piece.processingStatus} />
{piece.processingStatus === 'processando' ? (
<p className="mt-1 text-xs text-muted-foreground">
{piece.step ? contentPieceStepLabels[piece.step] : 'Processando…'}
</p>
) : piece.processingStatus === 'falhou' && piece.failureMessage ? (
<p className="mt-1 max-w-56 text-xs text-red-800">{piece.failureMessage}</p>
) : null}
</div>
),
},
{
id: 'publication',
label: 'Publicação',
cell: (piece) => <ContentPiecePublicationBadge status={piece.status} />,
},
{
id: 'action',
label: 'Próxima ação',
mandatory: true,
head: <CampaignTableHead align="right">Próxima ação</CampaignTableHead>,
cellClassName: 'text-right',
cell: (piece) =>
piece.canRetry ? (
<ContentPieceRetryButton
contentPieceId={piece.id}
label="Reprocessar"
className="inline-block"
/>
) : (
<Button asChild variant="outline" className="min-h-11">
<Link href={piece.detailHref}>Abrir</Link>
</Button>
),
},
]}
/>
)

View file

@ -0,0 +1,370 @@
'use client'
import { CircleAlertIcon, FileIcon, UploadIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
import type { ContentPieceUploadResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/types'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Spinner } from '@/components/ui/Spinner'
import { CAMPAIGN_CONTENT_PIECE_UPLOAD_HREF } from '@/lib/campaignPaths'
import {
CONTENT_PIECE_ACCEPT,
CONTENT_PIECE_BATCH_HELP,
CONTENT_PIECE_BATCH_LIMIT_MESSAGE,
CONTENT_PIECE_BATCH_MAX_FILES,
CONTENT_PIECE_MAX_BYTES,
CONTENT_PIECE_MAX_SIZE_LABEL,
contentPieceTooLargeMessage,
contentPieceTypeFromMime,
} from '@/lib/contentPiece'
import { formatRecordingFileSize } from '@/lib/recording'
import { cn } from '@/lib/utils'
const GENERIC_ERROR_MESSAGE = 'Não foi possível enviar a peça. Tente novamente.'
/** Two uploads in flight at once: real progress per file without saturating the box. */
const MAX_CONCURRENT_UPLOADS = 2
type FilePhase = 'queued' | 'uploading' | 'uploaded' | 'failed'
type BatchFile = {
/** Stable key: the File object identity survives a state update. */
id: string
file: File
phase: FilePhase
progress: number
error: string | null
}
const newId = (): string =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
/**
* C211 — the batch upload surface of the Central (approved design scenes 2 and
* 5): each picked file becomes an independent draft, uploads run two at a time
* with real `upload.onprogress`, and a failed file never stops the others. The
* same raw-body route as the C199 recording upload, one request per file.
*/
export const ContentPieceUploadDialog = ({
triggerClassName,
triggerVariant = 'default',
triggerLabel = 'Enviar peças',
triggerAriaLabel,
}: {
triggerClassName?: string
triggerVariant?: 'default' | 'outline'
triggerLabel?: string
triggerAriaLabel?: string
}) => {
const router = useRouter()
const fileInputRef = useRef<HTMLInputElement>(null)
const xhrsRef = useRef(new Map<string, XMLHttpRequest>())
const queueRef = useRef<string[]>([])
const filesRef = useRef<BatchFile[]>([])
const [open, setOpen] = useState(false)
const [files, setFiles] = useState<BatchFile[]>([])
const [notice, setNotice] = useState<string | null>(null)
const uploading = files.some((entry) => entry.phase === 'uploading')
useEffect(() => {
filesRef.current = files
}, [files])
// Navigating away unmounts the dialog: abort the in-flight uploads instead of
// leaving invisible multi-GB transfers running.
useEffect(
() => () => {
for (const xhr of xhrsRef.current.values()) xhr.abort()
xhrsRef.current.clear()
},
[],
)
const updateFile = (id: string, patch: Partial<BatchFile>) => {
setFiles((current) =>
current.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry)),
)
}
const reset = () => {
for (const xhr of xhrsRef.current.values()) xhr.abort()
xhrsRef.current.clear()
queueRef.current = []
setFiles([])
setNotice(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const drainQueue = () => {
while (xhrsRef.current.size < MAX_CONCURRENT_UPLOADS && queueRef.current.length > 0) {
const nextId = queueRef.current.shift()
if (!nextId) return
const entry = filesRef.current.find((item) => item.id === nextId)
if (entry && entry.phase === 'queued') uploadOne(entry)
}
}
const uploadOne = (entry: BatchFile) => {
const params = new URLSearchParams({ filename: entry.file.name })
const xhr = new XMLHttpRequest()
xhrsRef.current.set(entry.id, xhr)
updateFile(entry.id, { phase: 'uploading', progress: 0, error: null })
xhr.open('POST', `${CAMPAIGN_CONTENT_PIECE_UPLOAD_HREF}?${params.toString()}`)
xhr.setRequestHeader('Accept', 'application/json')
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && event.total > 0) {
updateFile(entry.id, { progress: Math.round((event.loaded / event.total) * 100) })
}
})
const settle = (patch: Partial<BatchFile>) => {
xhrsRef.current.delete(entry.id)
updateFile(entry.id, patch)
drainQueue()
}
xhr.addEventListener('load', () => {
try {
const payload = JSON.parse(xhr.responseText) as ContentPieceUploadResponse
if (xhr.status >= 200 && xhr.status < 300 && payload.status === 'success') {
settle({ phase: 'uploaded', progress: 100, error: null })
} else {
settle({
phase: 'failed',
error: payload.status === 'error' ? payload.message : GENERIC_ERROR_MESSAGE,
})
}
} catch {
settle({ phase: 'failed', error: GENERIC_ERROR_MESSAGE })
}
})
const onTransportError = () => settle({ phase: 'failed', error: GENERIC_ERROR_MESSAGE })
xhr.addEventListener('error', onTransportError)
xhr.addEventListener('abort', onTransportError)
xhr.send(entry.file)
}
const retry = (entry: BatchFile) => {
queueRef.current.push(entry.id)
updateFile(entry.id, { phase: 'queued', progress: 0, error: null })
window.setTimeout(() => drainQueue(), 0)
}
const onFilesChange = (event: ChangeEvent<HTMLInputElement>) => {
setNotice(null)
const picked = [...(event.target.files ?? [])]
if (fileInputRef.current) fileInputRef.current.value = ''
if (picked.length === 0) return
if (filesRef.current.length + picked.length > CONTENT_PIECE_BATCH_MAX_FILES) {
setNotice(CONTENT_PIECE_BATCH_LIMIT_MESSAGE)
return
}
const accepted: BatchFile[] = []
let refusedReason: string | null = null
for (const file of picked) {
if (contentPieceTypeFromMime(file.type, file.name) === null) {
refusedReason ??= 'Alguns arquivos foram ignorados: envie vídeo, áudio, foto ou texto.'
continue
}
if (file.size > CONTENT_PIECE_MAX_BYTES) {
refusedReason ??= contentPieceTooLargeMessage(file.size)
continue
}
accepted.push({ id: newId(), file, phase: 'queued', progress: 0, error: null })
}
setFiles((current) => [...current, ...accepted])
if (refusedReason) setNotice(refusedReason)
for (const entry of accepted) queueRef.current.push(entry.id)
// Let the state commit before the queue reads it.
window.setTimeout(() => drainQueue(), 0)
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (uploading) return
setOpen(next)
if (!next) reset()
}}
>
<DialogTrigger asChild>
<Button
variant={triggerVariant}
className={cn('min-h-11', triggerClassName)}
aria-label={triggerAriaLabel}
>
<UploadIcon data-icon="inline-start" aria-hidden="true" />
{triggerLabel}
</Button>
</DialogTrigger>
<DialogContent
className="sm:max-w-lg"
onInteractOutside={(event) => {
if (uploading) event.preventDefault()
}}
onEscapeKeyDown={(event) => {
if (uploading) event.preventDefault()
}}
>
<DialogHeader>
<DialogTitle className="border-b-0 pb-0">Enviar peças</DialogTitle>
<DialogDescription>{CONTENT_PIECE_BATCH_HELP}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div>
<input
ref={fileInputRef}
id="content-piece-files"
type="file"
multiple
accept={CONTENT_PIECE_ACCEPT}
className="sr-only"
onChange={onFilesChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex min-h-28 w-full flex-col items-center justify-center rounded-lg border border-dashed border-input bg-stone-50 px-4 text-center transition-colors hover:bg-stone-100"
>
<UploadIcon className="size-6 text-muted-foreground" aria-hidden="true" />
<span className="mt-2 text-sm font-medium">Selecionar arquivos</span>
<span className="mt-1 text-xs text-muted-foreground">
Vídeo, foto, áudio, texto ou card · até {CONTENT_PIECE_MAX_SIZE_LABEL} cada
</span>
</button>
{notice ? (
<Alert variant="destructive" className="mt-2 py-2" role="alert">
<CircleAlertIcon aria-hidden="true" />
<AlertDescription className="text-xs">{notice}</AlertDescription>
</Alert>
) : null}
</div>
{files.length > 0 ? (
<ul className="max-h-72 space-y-3 overflow-y-auto">
{files.map((entry) => (
<li
key={entry.id}
className={cn(
'rounded-lg border p-3',
entry.phase === 'failed' && 'border-red-200 bg-red-50',
)}
>
{entry.phase === 'uploading' ? (
<>
<div className="flex justify-between gap-3 text-sm">
<b className="min-w-0 truncate">{entry.file.name}</b>
<b className="tabular-nums">{entry.progress}%</b>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width]"
style={{ width: `${entry.progress}%` }}
/>
</div>
</>
) : (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<FileIcon
className="size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div className="min-w-0">
<b className="block truncate text-sm">{entry.file.name}</b>
{entry.phase === 'failed' ? (
<p className="mt-0.5 text-xs text-red-800">
{entry.error ?? GENERIC_ERROR_MESSAGE} Os outros continuam.
</p>
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
{formatRecordingFileSize(entry.file.size)} ·{' '}
{entry.phase === 'queued' ? 'na fila' : 'enviado'}
</p>
)}
</div>
</div>
{entry.phase === 'failed' ? (
<Button
type="button"
variant="outline"
className="min-h-11 shrink-0 md:min-h-9"
onClick={() => retry(entry)}
>
Tentar de novo
</Button>
) : entry.phase === 'uploaded' ? (
<span className="inline-flex shrink-0 items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-800">
Enviado
</span>
) : null}
</div>
)}
</li>
))}
</ul>
) : null}
{uploading ? (
<p className="rounded-lg bg-muted/70 p-3 text-xs leading-5 text-muted-foreground">
Cada arquivo vira um rascunho independente. Mantenha esta janela aberta até concluir.
</p>
) : null}
</div>
<DialogFooter>
{uploading ? (
<Button type="button" className="min-h-11 min-w-36" disabled>
<Spinner data-icon="inline-start" aria-hidden="true" />
Enviando…
</Button>
) : (
<>
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => {
setOpen(false)
reset()
}}
>
Cancelar
</Button>
<Button
type="button"
className="min-h-11"
disabled={files.length === 0}
onClick={() => {
reset()
setOpen(false)
router.refresh()
}}
>
Concluir lote
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -29,6 +29,14 @@ type CampaignHeaderFilterPopoverProps = {
id: string
label: string
optionRows: CampaignHeaderFilterRow[]
/**
* C211 — grouped facets behind ONE trigger (the approved "Filtros" button):
* each section renders a heading followed by its rows. Additive: without it
* the popover renders `optionRows` byte-for-byte as before (including the
* selected-first ordering, which is skipped for sections so the facet
* headings never interleave).
*/
sections?: readonly { label: string; rows: CampaignHeaderFilterRow[] }[]
/**
* C154 — chip trigger for the speech acervo filter ruler ("Ano: 2026 ▾").
* Default `'icon'` keeps every existing call site byte-identical.
@ -77,6 +85,7 @@ export const CampaignHeaderFilterPopover = ({
id,
label,
optionRows,
sections,
triggerVariant = 'icon',
triggerLabel,
}: CampaignHeaderFilterPopoverProps) => {
@ -89,7 +98,8 @@ export const CampaignHeaderFilterPopover = ({
* the B16 "click that undoes itself" bug taught).
*/
const [selectionSnapshot, setSelectionSnapshot] = useState<readonly string[]>([])
const searchable = optionRows.length >= SEARCHABLE_OPTION_THRESHOLD
const allRows = sections ? sections.flatMap((section) => section.rows) : optionRows
const searchable = allRows.length >= SEARCHABLE_OPTION_THRESHOLD
/**
* Selected-first ordering is multi-select only. Today every multi-select
* consumer marks every option row with `checkbox: true`, and every
@ -97,14 +107,14 @@ export const CampaignHeaderFilterPopover = ({
* without a new prop. Reordering a single-select would push "Todas" under
* the marked value, which the product rejected.
*/
const isMultiSelect = optionRows.length > 0 && optionRows.every((row) => row.checkbox)
const isMultiSelect = !sections && allRows.length > 0 && allRows.every((row) => row.checkbox)
const searchIndex = useMemo(
() =>
optionRows.map((row) => ({
allRows.map((row) => ({
row,
haystack: normalizeSearchPhrase(row.label),
})),
[optionRows],
[allRows],
)
const needle = normalizeSearchPhrase(query)
const filteredRows = needle
@ -132,13 +142,26 @@ export const CampaignHeaderFilterPopover = ({
const renderRows = (rows: readonly CampaignHeaderFilterRow[]) =>
rows.map((row) => <FilterRow key={row.value} row={row} onChoose={() => choose(row)} />)
/** C211 — the grouped rendering: heading per facet, rows in declaration order. */
const renderSections = () =>
sections?.map((section) => (
<div key={section.label} className="flex flex-col gap-0.5">
<p className="px-2 pt-2 pb-1 text-xs font-semibold text-foreground">{section.label}</p>
{renderRows(
needle
? section.rows.filter((row) => normalizeSearchPhrase(row.label).includes(needle))
: section.rows,
)}
</div>
))
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (next) {
setSelectionSnapshot(optionRows.filter((row) => row.selected).map((row) => row.value))
setSelectionSnapshot(allRows.filter((row) => row.selected).map((row) => row.value))
return
}
setQuery('')
@ -221,7 +244,9 @@ export const CampaignHeaderFilterPopover = ({
aria-labelledby={`${id}-heading`}
className="flex max-h-72 flex-col gap-0.5 overflow-y-auto"
>
{showSelectedDivider ? (
{sections ? (
renderSections()
) : showSelectedDivider ? (
<>
{renderRows(visibleRows.slice(0, selectedCount))}
<div className="my-1 border-t border-border" role="presentation" />
@ -230,7 +255,7 @@ export const CampaignHeaderFilterPopover = ({
) : (
renderRows(visibleRows)
)}
{visibleRows.length === 0 ? (
{!sections && visibleRows.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted-foreground">
{needle ? 'Nenhum resultado encontrado.' : emptyLabel}
</p>

View file

@ -22,6 +22,7 @@ import {
import {
CAMPAIGN_AGENDA_HOME,
CAMPAIGN_COMMUNICATION_ACERVO,
CAMPAIGN_COMMUNICATION_CONTEUDOS,
CAMPAIGN_COMMUNICATION_CORTES,
CAMPAIGN_COMMUNICATION_HOME,
CAMPAIGN_COMMUNICATION_REELS,
@ -58,12 +59,13 @@ export type CampaignNavItem = {
* C174 — the communication vertical's destinations, hung under its single
* top-level item. Naming the array keeps both consumers (sidebar and drawer)
* from spelling the list twice. C194 appended the reel library after the
* acervo and the cut library.
* acervo and the cut library; C211 appended the internal content Central.
*/
const communicationSubItems: readonly CampaignNavSubItem[] = [
{ title: 'Acervo de falas', href: CAMPAIGN_COMMUNICATION_ACERVO },
{ title: 'Biblioteca de cortes', href: CAMPAIGN_COMMUNICATION_CORTES },
{ title: 'Reels', href: CAMPAIGN_COMMUNICATION_REELS },
{ title: 'Conteúdos', href: CAMPAIGN_COMMUNICATION_CONTEUDOS },
]
/**

View file

@ -32,6 +32,8 @@ const CAMPAIGN_LIST_IDS = [
// C139 — the contacts page (staff) joins the picker with the same key
// contract; the ficha fields are the columns.
'contatos',
// C211 — the internal Central de Conteúdos list.
'conteudos',
] as const
export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number]

View file

@ -3,6 +3,7 @@ import type { Metadata } from 'next'
import {
CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES,
CAMPAIGN_COMMUNICATION_CONTEUDOS,
CAMPAIGN_COMMUNICATION_CORTES,
CAMPAIGN_COMMUNICATION_REELS,
CAMPAIGN_CONTACTS_HOME,
@ -134,6 +135,10 @@ export const campaignPageChromeCatalog = {
title: 'Reels',
subtitle: 'Tutoriais do site prontos para a assessoria baixar e publicar fora do Teqo.',
},
conteudos: {
title: 'Conteúdos',
subtitle: 'Peças que alimentam a Central pública.',
},
} as const satisfies Record<string, CampaignPageChrome | null>
const resolveCatalogEntry = (
@ -315,6 +320,12 @@ const pathRules: PathRule[] = [
match: (pathname) => pathname === CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES,
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.gravacoes),
},
{
// C211 — the content Central list. The `/<id>` ficha sets its own title
// through `SetCampaignPageChrome`, so it falls through to `null` here.
match: (pathname) => pathname === CAMPAIGN_COMMUNICATION_CONTEUDOS,
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.conteudos),
},
{
match: (pathname) => /^\/campanha\/comunicacao\/acervo\/[^/]+$/.test(pathname),
resolve: () => null,

View file

@ -54,6 +54,30 @@ export const campaignSpeechCutRetryHref = (id: number): string =>
export const CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES =
`${CAMPAIGN_COMMUNICATION_ACERVO}/gravacoes` as const
/** C211 — internal Central de Conteúdos (list + `/<id>` ficha). */
export const CAMPAIGN_COMMUNICATION_CONTEUDOS = '/campanha/comunicacao/conteudos' as const
const campaignContentPieceDetailHref = (id: number): string =>
`${CAMPAIGN_COMMUNICATION_CONTEUDOS}/${id}`
/** C211 — JSON endpoints of a piece (upload/link/status/retry/publication/file). */
export const CAMPAIGN_CONTENT_PIECE_UPLOAD_HREF =
`${CAMPAIGN_COMMUNICATION_CONTEUDOS}/enviar` as const
export const CAMPAIGN_CONTENT_PIECE_LINK_HREF = `${CAMPAIGN_COMMUNICATION_CONTEUDOS}/link` as const
export const CAMPAIGN_CONTENT_PIECE_STATUS_HREF =
`${CAMPAIGN_COMMUNICATION_CONTEUDOS}/status` as const
export const campaignContentPieceFileHref = (id: number, download = false): string =>
`${campaignContentPieceDetailHref(id)}/arquivo${download ? '?download=1' : ''}`
export const campaignContentPieceRetryHref = (id: number): string =>
`${campaignContentPieceDetailHref(id)}/retry`
export const campaignContentPiecePublicationHref = (id: number): string =>
`${campaignContentPieceDetailHref(id)}/publicacao`
const campaignRecordingDetailHref = (id: number): string =>
`${CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES}/${id}`

525
src/lib/contentPiece.ts Normal file
View file

@ -0,0 +1,525 @@
/**
* C211 — pure rules of a campaign content piece ("peça"): the type/status/step
* vocabulary, the upload limits, the honest failure copy and the link
* normalization. No I/O and no `server-only`: the client dialogs, the JSON
* routes, the job, the loaders and the unit tests share this module.
*
* The piece is the internal catalogue the public Central (S27) consumes: this
* module never implements a public surface — it owns the vocabulary and the
* derivations both sides agree on.
*/
import {
CAMPAIGN_COMMUNICATION_CONTEUDOS,
campaignContentPieceFileHref,
campaignContentPieceRetryHref,
} from '@/lib/campaignPaths'
import { formatRecordingFileSize } from '@/lib/recording'
import { formatSpeechClock, formatSpeechDate } from '@/lib/speechClock'
import { SPEECH_TOPICS, type SpeechTopic } from '@/lib/speechFacets'
import { normalizeForSearch } from '@/lib/speechSearch'
/** Owner of the private upload collection slug (collection, config and routes). */
export const CONTENT_MEDIA_SLUG = 'contentMedia' as const
export const CONTENT_PIECE_TYPES = ['video', 'foto', 'texto', 'audio', 'card'] as const
export type ContentPieceType = (typeof CONTENT_PIECE_TYPES)[number]
export const contentPieceTypeLabels: Record<ContentPieceType, string> = {
video: 'Vídeo',
foto: 'Foto',
texto: 'Texto',
audio: 'Áudio',
card: 'Card',
}
export const isContentPieceType = (value: unknown): value is ContentPieceType =>
CONTENT_PIECE_TYPES.includes(value as ContentPieceType)
export const CONTENT_PIECE_STATUSES = ['rascunho', 'publicado'] as const
export type ContentPieceStatus = (typeof CONTENT_PIECE_STATUSES)[number]
export const contentPieceStatusLabels: Record<ContentPieceStatus, string> = {
rascunho: 'Rascunho',
publicado: 'Publicado',
}
export const isContentPieceStatus = (value: unknown): value is ContentPieceStatus =>
CONTENT_PIECE_STATUSES.includes(value as ContentPieceStatus)
export const CONTENT_PIECE_PROCESSING_STATUSES = ['processando', 'pronto', 'falhou'] as const
export type ContentPieceProcessingStatus = (typeof CONTENT_PIECE_PROCESSING_STATUSES)[number]
export const contentPieceProcessingStatusLabels: Record<ContentPieceProcessingStatus, string> = {
processando: 'Processando',
pronto: 'Pronto',
falhou: 'Falhou',
}
export const isContentPieceProcessingStatus = (
value: unknown,
): value is ContentPieceProcessingStatus =>
CONTENT_PIECE_PROCESSING_STATUSES.includes(value as ContentPieceProcessingStatus)
export const CONTENT_PIECE_STEPS = [
'extraindo',
'transcrevendo',
'catalogando',
'salvando',
] as const
export type ContentPieceStep = (typeof CONTENT_PIECE_STEPS)[number]
export const contentPieceStepLabels: Record<ContentPieceStep, string> = {
extraindo: 'Preparando a mídia da peça',
transcrevendo: 'Transcrevendo a peça',
catalogando: 'Catalogando a peça',
salvando: 'Guardando a catalogação',
}
export const isContentPieceStep = (value: unknown): value is ContentPieceStep =>
CONTENT_PIECE_STEPS.includes(value as ContentPieceStep)
export const CONTENT_PIECE_ORIGINS = ['arquivo', 'instagram', 'youtube'] as const
export type ContentPieceOrigin = (typeof CONTENT_PIECE_ORIGINS)[number]
export const contentPieceOriginLabels: Record<ContentPieceOrigin, string> = {
arquivo: 'Arquivo enviado',
instagram: 'Instagram',
youtube: 'YouTube',
}
export const isContentPieceOrigin = (value: unknown): value is ContentPieceOrigin =>
CONTENT_PIECE_ORIGINS.includes(value as ContentPieceOrigin)
const topicValues = new Set<string>(SPEECH_TOPICS.map((topic) => topic.value))
export const isContentPieceTopic = (value: unknown): value is SpeechTopic =>
topicValues.has(value as SpeechTopic)
export const contentPieceTopicLabel = (value: SpeechTopic): string =>
SPEECH_TOPICS.find((topic) => topic.value === value)?.label ?? value
/**
* The catalog fields the pipeline may fill and the assessoria may curate. The
* job never overwrites a field listed in `curatedFields` (D6) — the vocabulary
* is persisted, so it stays one literal per field name.
*/
export const CONTENT_PIECE_CURATED_FIELDS = [
'title',
'description',
'topics',
'municipality',
'institution',
'pieceDate',
'transcript',
'type',
] as const
export type ContentPieceCuratedField = (typeof CONTENT_PIECE_CURATED_FIELDS)[number]
export const isContentPieceCuratedField = (value: unknown): value is ContentPieceCuratedField =>
CONTENT_PIECE_CURATED_FIELDS.includes(value as ContentPieceCuratedField)
/** pt-BR names of the curated fields for the admin's read-only chip list. */
export const contentPieceCuratedFieldLabels: Record<ContentPieceCuratedField, string> = {
title: 'Título',
description: 'Descrição',
topics: 'Temas',
municipality: 'Cidade',
institution: 'Instituição',
pieceDate: 'Data da peça',
transcript: 'Transcrição / texto',
type: 'Tipo',
}
/** One owner for the persisted field limits (collection, schema and dialogs). */
export const CONTENT_PIECE_TITLE_MAX_LENGTH = 200
export const CONTENT_PIECE_DESCRIPTION_MAX_LENGTH = 1200
export const CONTENT_PIECE_INSTITUTION_MAX_LENGTH = 160
/** Batch policy: a generous ceiling with a clear message above it (product A). */
export const CONTENT_PIECE_BATCH_MAX_FILES = 50
/** Per-file ceiling: the same guard as a recording — hours of media stream, never buffer. */
export const CONTENT_PIECE_MAX_BYTES = 4 * 1024 * 1024 * 1024
export const CONTENT_PIECE_MAX_SIZE_LABEL = '4 GB'
/** A text piece is a document, not a media dump; the extraction reads it whole. */
export const CONTENT_PIECE_TEXT_MAX_BYTES = 5 * 1024 * 1024
const CONTENT_PIECE_TEXT_MAX_SIZE_LABEL = '5 MB'
/** The video/audio extraction timeout has no duration to scale from; 30 min is ample. */
export const CONTENT_PIECE_FFMPEG_TIMEOUT_MS = 30 * 60_000
/**
* The causes the job and the reaper store in `error` (the operator's detail)
* and the honest pt-BR copy the detail shows. A raw transport message
* (URL, stderr) stays in the admin.
*/
export const CONTENT_PIECE_FAILURE_INTERRUPTED =
'O processamento foi interrompido antes de terminar.'
const CONTENT_PIECE_FAILURE_EXTRACTING_COPY = 'Não foi possível preparar a mídia da peça.'
const CONTENT_PIECE_FAILURE_TRANSCRIBING_COPY = 'Não foi possível transcrever a peça.'
const CONTENT_PIECE_FAILURE_CATALOGING_COPY = 'Não foi possível catalogar a peça.'
const CONTENT_PIECE_FAILURE_SAVING_COPY = 'Não foi possível guardar a catalogação.'
const CONTENT_PIECE_FAILURE_UNKNOWN_COPY = 'Não foi possível processar a peça.'
/**
* Maps the stored cause of a failed piece to what the person reads: the exact
* literal when the job named it, otherwise the failure step, otherwise a
* generic-but-honest line. Null when the row stored no cause at all.
*/
export const contentPieceFailureMessage = ({
error,
step,
}: {
error?: string | null
step?: ContentPieceStep | null
}): string | null => {
const stored = error?.trim()
if (!stored) return null
if (stored === CONTENT_PIECE_FAILURE_INTERRUPTED) return CONTENT_PIECE_FAILURE_INTERRUPTED
if (step === 'extraindo') return CONTENT_PIECE_FAILURE_EXTRACTING_COPY
if (step === 'transcrevendo') return CONTENT_PIECE_FAILURE_TRANSCRIBING_COPY
if (step === 'catalogando') return CONTENT_PIECE_FAILURE_CATALOGING_COPY
if (step === 'salvando') return CONTENT_PIECE_FAILURE_SAVING_COPY
return CONTENT_PIECE_FAILURE_UNKNOWN_COPY
}
/** The retry button only applies to a failed piece. */
export const canRetryContentPiece = (processingStatus: ContentPieceProcessingStatus): boolean =>
processingStatus === 'falhou'
/**
* What the pipeline runs for each type: video and audio are transcribed, text
* has its content extracted. Photo and card are ready on upload (the aceite
* does not ask for transcription; the cataloguing still fills the metadata).
*/
export const needsContentPieceProcessing = (type: ContentPieceType): boolean =>
type === 'video' || type === 'audio' || type === 'texto'
/**
* The S27 contract, as a pure predicate: a piece is public only once published
* AND with something to show (an archived file or the platform link).
*/
export const contentPieceIsPublic = (piece: {
status: ContentPieceStatus
hasFile: boolean
sourceUrl: string | null
}): boolean => piece.status === 'publicado' && (piece.hasFile || Boolean(piece.sourceUrl))
const VIDEO_EXTENSIONS = ['mp4', 'mov', 'mkv', 'webm', 'm4v', 'avi']
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'm4a', 'aac', 'ogg', 'oga', 'opus', 'flac']
const TEXT_EXTENSIONS = ['txt', 'md', 'markdown', 'csv', 'text']
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif', 'gif', 'avif']
const extensionOf = (filename: string | null | undefined): string =>
(filename ?? '')
.toLowerCase()
.split('.')
.pop()
?.replace(/[^a-z0-9]/g, '') ?? ''
/**
* The piece type the upload derives from the file. MIME is the primary signal
* (`video/*` cannot be a photo), the extension the fallback for the formats the
* OS often reports without a type (the C199 `.mkv` lesson). Images land as
* `foto` — whether the image is a card is an editorial call the assessoria
* makes on the ficha, and the type is editable; a card is not a distinct file.
* Null means the upload refuses the file.
*/
export const contentPieceTypeFromMime = (
mimeType: string | null | undefined,
filename?: string | null,
): ContentPieceType | null => {
const mime = (mimeType ?? '').toLowerCase()
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
if (mime.startsWith('text/')) return 'texto'
if (mime.startsWith('image/')) return 'foto'
const extension = extensionOf(filename)
if (VIDEO_EXTENSIONS.includes(extension)) return 'video'
if (AUDIO_EXTENSIONS.includes(extension)) return 'audio'
if (TEXT_EXTENSIONS.includes(extension)) return 'texto'
if (IMAGE_EXTENSIONS.includes(extension)) return 'foto'
return null
}
/** The file types the picker offers — the four families the catalogue reads. */
export const CONTENT_PIECE_ACCEPT =
'video/*,audio/*,image/*,text/*,.mkv,.mov,.mp4,.webm,.mp3,.wav,.m4a,.ogg,.opus,.txt,.md,.markdown'
/** Basename without path parts or unsafe characters, keeping the extension. */
export const sanitizeContentPieceFilename = (filename: string): string => {
const base = filename.split(/[\\/]/).pop() ?? ''
const safe = base.replace(/[^\w.-]+/g, '_').replace(/^[._]+/, '')
return safe || 'peca'
}
export const contentPieceTooLargeMessage = (bytes: number): string =>
`Este arquivo tem ${formatRecordingFileSize(bytes)}. Escolha uma peça de até ${CONTENT_PIECE_MAX_SIZE_LABEL}.`
export const CONTENT_PIECE_BATCH_LIMIT_MESSAGE = `Envie até ${CONTENT_PIECE_BATCH_MAX_FILES} arquivos por lote.`
export const CONTENT_PIECE_TEXT_TOO_LARGE_MESSAGE = `O texto excede o limite de ${CONTENT_PIECE_TEXT_MAX_SIZE_LABEL}.`
/** Dialog copy of the batch help (approved design, scene 2). */
export const CONTENT_PIECE_BATCH_HELP = 'Cada arquivo vira um rascunho independente.'
/**
* The piece title starts from the filename (the assessoria renames it; the
* automatic cataloguing replaces it while the field is not curated).
*/
export const contentPieceTitleFromFilename = (filename: string): string => {
const base = (filename.split(/[\\/]/).pop() ?? '').replace(/\.[^.]+$/, '')
const cleaned = base.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim()
return cleaned || 'Nova peça'
}
/**
* The slug base of a piece: accent/case-insensitive, alphanumeric groups joined
* by `-`. Empty when the title has no usable character.
*/
export const contentPieceSlugBase = (title: string): string =>
title
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80)
/** The slug candidates in order: base, then `-2`, `-3`… (uniqueness lives in the DB). */
export const contentPieceSlugCandidates = (title: string, maximum = 50): string[] => {
const base = contentPieceSlugBase(title) || 'peca'
const candidates = [base]
for (let suffix = 2; candidates.length < maximum; suffix += 1) {
candidates.push(`${base}-${suffix}`)
}
return candidates
}
type ContentPieceSearchInput = {
title?: string | null
description?: string | null
transcript?: string | null
institution?: string | null
topics?: readonly string[] | null
cityLabel?: string | null
}
/**
* The normalized haystack the list search matches: title, description, the
* transcript/text, the topic labels and the city/institution — everything the
* design's placeholder promises ("Buscar por título, tema ou cidade…").
*/
export const contentPieceSearchText = (input: ContentPieceSearchInput): string =>
normalizeForSearch(
[
input.title ?? '',
input.description ?? '',
input.transcript ?? '',
input.institution ?? '',
...(input.topics ?? []).filter(isContentPieceTopic).map(contentPieceTopicLabel),
input.cityLabel ?? '',
]
.map((value) => value.trim())
.filter(Boolean)
.join(' '),
)
type ContentPieceInstagramLink = {
origin: 'instagram'
canonicalUrl: string
shortcode: string
}
type ContentPieceYoutubeLink = {
origin: 'youtube'
canonicalUrl: string
videoId: string
}
export type ContentPieceLink = ContentPieceInstagramLink | ContentPieceYoutubeLink
const INSTAGRAM_KINDS = ['p', 'reel', 'reels', 'tv']
/**
* Normalizes an Instagram or YouTube link into its canonical form, dropping
* tracking parameters and hash. The shortcode/video id is the identity, so two
* pastes of the same post collapse into one `sourceUrl` (unique in the DB).
* Returns null for any other host — the piece only accepts the two platforms.
*/
export const parseContentPieceLink = (raw: string | null | undefined): ContentPieceLink | null => {
const trimmed = (raw ?? '').trim()
if (!trimmed) return null
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
const host = url.hostname.toLowerCase().replace(/^www\.|^m\./, '')
const segments = url.pathname.split('/').filter(Boolean)
if (host === 'instagram.com') {
const [kind, shortcode] = segments
if (!kind || !shortcode || !INSTAGRAM_KINDS.includes(kind.toLowerCase())) return null
const safeShortcode = shortcode.replace(/[^A-Za-z0-9_-]/g, '')
if (!safeShortcode) return null
const canonicalKind = kind.toLowerCase() === 'reels' ? 'reel' : kind.toLowerCase()
return {
origin: 'instagram',
shortcode: safeShortcode,
canonicalUrl: `https://www.instagram.com/${canonicalKind}/${safeShortcode}/`,
}
}
if (host === 'youtube.com' || host === 'youtu.be') {
const videoId =
host === 'youtu.be'
? (segments[0] ?? '')
: segments[0] === 'watch'
? (url.searchParams.get('v') ?? '')
: ['shorts', 'live', 'embed'].includes(segments[0] ?? '')
? (segments[1] ?? '')
: ''
const safeVideoId = videoId.replace(/[^A-Za-z0-9_-]/g, '')
if (!safeVideoId) return null
return {
origin: 'youtube',
videoId: safeVideoId,
canonicalUrl: `https://www.youtube.com/watch?v=${safeVideoId}`,
}
}
return null
}
/** The platform of a link, or null when it is not an accepted one. */
export const contentPieceOriginFromLink = (
raw: string | null | undefined,
): ContentPieceOrigin | null => parseContentPieceLink(raw)?.origin ?? null
/** A short, identifyable provisional title while the cataloguing has not run. */
export const contentPieceLinkTitle = (link: ContentPieceLink): string =>
link.origin === 'instagram' ? `Instagram · ${link.shortcode}` : `YouTube · ${link.videoId}`
export type ContentPieceViewModel = {
id: number
title: string
type: ContentPieceType
status: ContentPieceStatus
processingStatus: ContentPieceProcessingStatus
step: ContentPieceStep | null
origin: ContentPieceOrigin
typeLabel: string
originLabel: string
statusLabel: string
processingLabel: string
topics: SpeechTopic[]
cityLabel: string | null
regionLabel: string | null
durationLabel: string | null
pieceDateLabel: string | null
publishedAtLabel: string | null
failureMessage: string | null
canRetry: boolean
isPublished: boolean
hasFile: boolean
detailHref: string
fileHref: string | null
retryHref: string
}
type ContentPieceViewRecord = {
id: number
title?: string | null
type?: string | null
status?: string | null
processingStatus?: string | null
step?: string | null
origin?: string | null
topics?: string[] | null
cityLabel?: string | null
region?: string | null
durationSeconds?: number | null
pieceDate?: string | null
publishedAt?: string | null
error?: string | null
media?: number | { id: number } | null
}
const normalizedType = (value: string | null | undefined): ContentPieceType =>
isContentPieceType(value) ? value : 'foto'
const normalizedStatus = (value: string | null | undefined): ContentPieceStatus =>
isContentPieceStatus(value) ? value : 'rascunho'
const normalizedProcessingStatus = (
value: string | null | undefined,
): ContentPieceProcessingStatus => (isContentPieceProcessingStatus(value) ? value : 'falhou')
const normalizedOrigin = (value: string | null | undefined): ContentPieceOrigin =>
isContentPieceOrigin(value) ? value : 'arquivo'
const dateLabelOf = (value: string | null | undefined): string | null =>
/^(\d{4})-(\d{2})-(\d{2})/.test(value ?? '') && value ? formatSpeechDate(value) : null
const durationLabelOf = (seconds: number | null | undefined): string | null =>
typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0
? formatSpeechClock(seconds)
: null
const mediaIdOf = (media: ContentPieceViewRecord['media']): number | null =>
typeof media === 'number' ? media : (media?.id ?? null)
/**
* Internal/status wire view of a piece. `error` and `createdBy` never pass
* through raw: a failure becomes the mapped `failureMessage`.
*/
export const toContentPieceViewModel = (record: ContentPieceViewRecord): ContentPieceViewModel => {
const processingStatus = normalizedProcessingStatus(record.processingStatus)
const step = isContentPieceStep(record.step) ? record.step : null
const type = normalizedType(record.type)
const status = normalizedStatus(record.status)
const origin = normalizedOrigin(record.origin)
const hasFile = mediaIdOf(record.media) !== null
return {
id: record.id,
title: record.title?.trim() || `Peça ${record.id}`,
type,
status,
processingStatus,
step,
origin,
typeLabel: contentPieceTypeLabels[type],
originLabel: contentPieceOriginLabels[origin],
statusLabel: contentPieceStatusLabels[status],
processingLabel: contentPieceProcessingStatusLabels[processingStatus],
topics: (record.topics ?? []).filter(isContentPieceTopic),
cityLabel: record.cityLabel?.trim() || null,
regionLabel: record.region?.trim() || null,
durationLabel: durationLabelOf(record.durationSeconds),
pieceDateLabel: dateLabelOf(record.pieceDate),
publishedAtLabel: dateLabelOf(record.publishedAt),
failureMessage: contentPieceFailureMessage({ error: record.error, step }),
canRetry: canRetryContentPiece(processingStatus),
isPublished: status === 'publicado',
hasFile,
detailHref: `${CAMPAIGN_COMMUNICATION_CONTEUDOS}/${record.id}`,
fileHref: hasFile ? campaignContentPieceFileHref(record.id) : null,
retryHref: campaignContentPieceRetryHref(record.id),
}
}

View file

@ -0,0 +1,145 @@
import { z } from 'zod'
import {
CONTENT_PIECE_DESCRIPTION_MAX_LENGTH,
CONTENT_PIECE_INSTITUTION_MAX_LENGTH,
CONTENT_PIECE_TEXT_TOO_LARGE_MESSAGE,
CONTENT_PIECE_TITLE_MAX_LENGTH,
CONTENT_PIECE_TYPES,
} from '@/lib/contentPiece'
import { trimmedNullableText } from '@/lib/schemas/primitives'
/** The Central gate refused the actor — one literal shared with the vertical. */
export const CONTENT_PIECE_FORBIDDEN_MESSAGE = 'Seu perfil não tem acesso à Central de Conteúdos.'
/** The piece does not exist (or the actor cannot read it). */
export const CONTENT_PIECE_NOT_FOUND_MESSAGE = 'Peça não encontrada.'
/** Retry only applies to a failed piece. */
export const CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE =
'Só é possível reprocessar uma peça que falhou.'
/** Transport/storage failure (or anything unmapped) of a JSON action. */
export const CONTENT_PIECE_GENERIC_ERROR_MESSAGE =
'Não foi possível concluir a ação. Verifique seu acesso e tente novamente.'
/** The pasted link is not an Instagram/YouTube publication. */
export const CONTENT_PIECE_LINK_INVALID_MESSAGE = 'Cole um link do Instagram ou do YouTube.'
/** The same link is already catalogued (the `sourceUrl` unique constraint). */
export const CONTENT_PIECE_LINK_DUPLICATE_MESSAGE = 'Esta peça já está na Central.'
/** The upload arrived without a usable file name (a field error). */
const CONTENT_PIECE_FILE_NAME_MESSAGE = 'Nome de arquivo inválido.'
/** The picked file is outside the four families the Central catalogues. */
export const CONTENT_PIECE_FILE_TYPE_MESSAGE = 'Envie um vídeo, um áudio, uma foto ou um texto.'
/** The raw-body upload arrived without a body (shared with the upload module). */
export const CONTENT_PIECE_BODY_MISSING_MESSAGE = 'A requisição não trouxe o arquivo da peça.'
/** The stream guard's ceiling refusal (the picker's per-file copy is dynamic). */
export const CONTENT_PIECE_UPLOAD_TOO_LARGE_MESSAGE = 'O arquivo excede o limite de 4 GB.'
/** "Anexar arquivo original" on a piece that already has a file. */
export const CONTENT_PIECE_MEDIA_ALREADY_ATTACHED_MESSAGE = 'Esta peça já tem um arquivo original.'
/** The ficha sent a type outside the vocabulary. */
export const CONTENT_PIECE_TYPE_INVALID_MESSAGE = 'Tipo de peça inválido.'
/** The ficha saved an empty title (a field error, never a safe wire message). */
const CONTENT_PIECE_TITLE_REQUIRED_MESSAGE = 'Informe um título para a peça.'
/** Filename sanity: no path parts, no empties — the temp file keeps the extension. */
const uploadFilename = z
.string()
.trim()
.min(1, CONTENT_PIECE_FILE_NAME_MESSAGE)
.max(255, CONTENT_PIECE_FILE_NAME_MESSAGE)
.refine((value) => !value.includes('/') && !value.includes('\\'), CONTENT_PIECE_FILE_NAME_MESSAGE)
/** Metadata of the raw-body upload (`POST .../conteudos/enviar`). */
export const contentPieceUploadMetadataSchema = z.object({
filename: uploadFilename,
})
export type ContentPieceUploadMetadata = z.infer<typeof contentPieceUploadMetadataSchema>
/** Add a piece by link (Instagram/YouTube). */
export const contentPieceLinkRequestSchema = z.object({
url: z.string().trim().min(1, CONTENT_PIECE_LINK_INVALID_MESSAGE),
})
/** The editable catalogue of one piece (the ficha form). */
export const contentPieceUpdateRequestSchema = z.object({
contentPieceId: z.number().int().positive(),
title: z
.string()
.trim()
.min(1, CONTENT_PIECE_TITLE_REQUIRED_MESSAGE)
.max(
CONTENT_PIECE_TITLE_MAX_LENGTH,
`O título deve ter até ${CONTENT_PIECE_TITLE_MAX_LENGTH} caracteres.`,
),
description: trimmedNullableText(CONTENT_PIECE_DESCRIPTION_MAX_LENGTH),
type: z.enum(CONTENT_PIECE_TYPES),
pieceDate: trimmedNullableText(32),
// Kept as raw tokens: the action filters them against the taxonomy and the
// field counts as curated whenever the form carried it at all.
topics: z.array(z.string()).max(32).optional(),
municipalityId: z.number().int().positive().nullable().optional(),
institution: trimmedNullableText(CONTENT_PIECE_INSTITUTION_MAX_LENGTH),
transcript: trimmedNullableText(200_000),
})
export type ContentPieceUpdateRequest = z.infer<typeof contentPieceUpdateRequestSchema>
/** Retry the processing of a failed piece. */
export const contentPieceRetryRequestSchema = z.object({
contentPieceId: z.number().int().positive(),
})
/** Poll the statuses of the visible pieces (bounded batch). */
export const contentPieceStatusRequestSchema = z.object({
contentPieceIds: z.array(z.number().int().positive()).min(1).max(50),
})
export type ContentPieceStatusRequest = z.infer<typeof contentPieceStatusRequestSchema>
/** The kill switch of one piece. */
export const contentPiecePublicationRequestSchema = z.object({
contentPieceId: z.number().int().positive(),
published: z.boolean(),
})
/** Attach the original file to a link piece. */
export const contentPieceAttachRequestSchema = z.object({
contentPieceId: z.number().int().positive(),
filename: uploadFilename,
})
/**
* The domain messages the JSON routes and the ficha form are allowed to pass
* through verbatim (everything else collapses to the generic one).
*/
export const CONTENT_PIECE_SAFE_MESSAGES = [
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_NOT_FOUND_MESSAGE,
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
CONTENT_PIECE_LINK_INVALID_MESSAGE,
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
CONTENT_PIECE_MEDIA_ALREADY_ATTACHED_MESSAGE,
CONTENT_PIECE_TYPE_INVALID_MESSAGE,
] as const
/**
* The messages the two raw-body piece routes (upload and attach) may return
* verbatim; the JSON routes use `CONTENT_PIECE_SAFE_MESSAGES` alone.
*/
export const CONTENT_PIECE_UPLOAD_SAFE_MESSAGES = [
...CONTENT_PIECE_SAFE_MESSAGES,
CONTENT_PIECE_FILE_TYPE_MESSAGE,
CONTENT_PIECE_BODY_MISSING_MESSAGE,
CONTENT_PIECE_UPLOAD_TOO_LARGE_MESSAGE,
CONTENT_PIECE_TEXT_TOO_LARGE_MESSAGE,
] as const

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,129 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TYPE "public"."enum_content_piece_topics" AS ENUM('saude', 'educacao', 'cultura', 'esporte', 'seguranca-publica', 'meio-ambiente', 'economia-trabalho', 'direitos-humanos', 'infraestrutura', 'ciencia-tecnologia', 'politica-instituicoes', 'agricultura', 'habitacao-cidades', 'comunicacao-midia', 'igualdade-racial', 'mulheres-genero', 'juventude', 'pessoa-deficiencia');
CREATE TYPE "public"."enum_content_piece_curated_fields" AS ENUM('title', 'description', 'topics', 'municipality', 'institution', 'pieceDate', 'transcript', 'type');
CREATE TYPE "public"."enum_content_piece_type" AS ENUM('video', 'foto', 'texto', 'audio', 'card');
CREATE TYPE "public"."enum_content_piece_origin" AS ENUM('arquivo', 'instagram', 'youtube');
CREATE TYPE "public"."enum_content_piece_status" AS ENUM('rascunho', 'publicado');
CREATE TYPE "public"."enum_content_piece_processing_status" AS ENUM('processando', 'pronto', 'falhou');
CREATE TYPE "public"."enum_content_piece_step" AS ENUM('extraindo', 'transcrevendo', 'catalogando', 'salvando');
CREATE TABLE "content_piece_topics" (
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"value" "enum_content_piece_topics",
"id" serial PRIMARY KEY NOT NULL
);
CREATE TABLE "content_piece_curated_fields" (
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"value" "enum_content_piece_curated_fields",
"id" serial PRIMARY KEY NOT NULL
);
CREATE TABLE "content_piece" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar NOT NULL,
"slug" varchar,
"type" "enum_content_piece_type" NOT NULL,
"description" varchar,
"municipality_id" integer,
"city_label" varchar,
"region" varchar,
"institution" varchar,
"piece_date" timestamp(3) with time zone,
"duration_seconds" numeric,
"transcript" varchar,
"media_id" integer,
"source_url" varchar,
"origin" "enum_content_piece_origin" DEFAULT 'arquivo' NOT NULL,
"status" "enum_content_piece_status" DEFAULT 'rascunho' NOT NULL,
"published_at" timestamp(3) with time zone,
"processing_status" "enum_content_piece_processing_status" DEFAULT 'pronto' NOT NULL,
"step" "enum_content_piece_step",
"error" varchar,
"search_text" varchar,
"created_by_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "content_media" (
"id" serial PRIMARY KEY NOT NULL,
"alt" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "content_piece_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "content_media_id" integer;
ALTER TABLE "content_piece_topics" ADD CONSTRAINT "content_piece_topics_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."content_piece"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "content_piece_curated_fields" ADD CONSTRAINT "content_piece_curated_fields_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."content_piece"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "content_piece" ADD CONSTRAINT "content_piece_municipality_id_municipality_id_fk" FOREIGN KEY ("municipality_id") REFERENCES "public"."municipality"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "content_piece" ADD CONSTRAINT "content_piece_media_id_content_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "public"."content_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "content_piece" ADD CONSTRAINT "content_piece_created_by_id_campaign_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "content_piece_topics_order_idx" ON "content_piece_topics" USING btree ("order");
CREATE INDEX "content_piece_topics_parent_idx" ON "content_piece_topics" USING btree ("parent_id");
CREATE INDEX "content_piece_topics_value_idx" ON "content_piece_topics" USING btree ("value");
CREATE INDEX "content_piece_curated_fields_order_idx" ON "content_piece_curated_fields" USING btree ("order");
CREATE INDEX "content_piece_curated_fields_parent_idx" ON "content_piece_curated_fields" USING btree ("parent_id");
CREATE UNIQUE INDEX "content_piece_slug_idx" ON "content_piece" USING btree ("slug");
CREATE INDEX "content_piece_type_idx" ON "content_piece" USING btree ("type");
CREATE INDEX "content_piece_municipality_idx" ON "content_piece" USING btree ("municipality_id");
CREATE INDEX "content_piece_piece_date_idx" ON "content_piece" USING btree ("piece_date");
CREATE INDEX "content_piece_media_idx" ON "content_piece" USING btree ("media_id");
CREATE UNIQUE INDEX "content_piece_source_url_idx" ON "content_piece" USING btree ("source_url");
CREATE INDEX "content_piece_status_idx" ON "content_piece" USING btree ("status");
CREATE INDEX "content_piece_processing_status_idx" ON "content_piece" USING btree ("processing_status");
CREATE INDEX "content_piece_created_by_idx" ON "content_piece" USING btree ("created_by_id");
CREATE INDEX "content_piece_updated_at_idx" ON "content_piece" USING btree ("updated_at");
CREATE INDEX "content_piece_created_at_idx" ON "content_piece" USING btree ("created_at");
CREATE INDEX "content_media_updated_at_idx" ON "content_media" USING btree ("updated_at");
CREATE INDEX "content_media_created_at_idx" ON "content_media" USING btree ("created_at");
CREATE UNIQUE INDEX "content_media_filename_idx" ON "content_media" USING btree ("filename");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_content_piece_fk" FOREIGN KEY ("content_piece_id") REFERENCES "public"."content_piece"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_content_media_fk" FOREIGN KEY ("content_media_id") REFERENCES "public"."content_media"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_content_piece_id_idx" ON "payload_locked_documents_rels" USING btree ("content_piece_id");
CREATE INDEX "payload_locked_documents_rels_content_media_id_idx" ON "payload_locked_documents_rels" USING btree ("content_media_id");
CREATE INDEX IF NOT EXISTS "content_piece_search_text_trgm_idx" ON "content_piece" USING gin ("search_text" gin_trgm_ops);`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP INDEX IF EXISTS "content_piece_search_text_trgm_idx";
ALTER TABLE "content_piece_topics" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "content_piece_curated_fields" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "content_piece" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "content_media" DISABLE ROW LEVEL SECURITY;
DROP TABLE "content_piece_topics" CASCADE;
DROP TABLE "content_piece_curated_fields" CASCADE;
DROP TABLE "content_piece" CASCADE;
DROP TABLE "content_media" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_content_piece_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_content_media_fk";
DROP INDEX "payload_locked_documents_rels_content_piece_id_idx";
DROP INDEX "payload_locked_documents_rels_content_media_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "content_piece_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "content_media_id";
DROP TYPE "public"."enum_content_piece_topics";
DROP TYPE "public"."enum_content_piece_curated_fields";
DROP TYPE "public"."enum_content_piece_type";
DROP TYPE "public"."enum_content_piece_origin";
DROP TYPE "public"."enum_content_piece_status";
DROP TYPE "public"."enum_content_piece_processing_status";
DROP TYPE "public"."enum_content_piece_step";`)
}

View file

@ -78,6 +78,7 @@ import * as migration_20260919_003440_add_recording from './20260919_003440_add_
import * as migration_20260919_032729_add_recording_speakers from './20260919_032729_add_recording_speakers';
import * as migration_20260919_155232_add_share_link from './20260919_155232_add_share_link';
import * as migration_20260920_031320_add_jingle from './20260920_031320_add_jingle';
import * as migration_20260923_032714_add_content_piece from './20260923_032714_add_content_piece';
export const migrations = [
{
@ -478,6 +479,11 @@ export const migrations = [
{
up: migration_20260920_031320_add_jingle.up,
down: migration_20260920_031320_add_jingle.down,
name: '20260920_031320_add_jingle'
name: '20260920_031320_add_jingle',
},
{
up: migration_20260923_032714_add_content_piece.up,
down: migration_20260923_032714_add_content_piece.down,
name: '20260923_032714_add_content_piece'
},
];

View file

@ -91,6 +91,8 @@ export interface Config {
speechCut: SpeechCut;
reel: Reel;
reelMedia: ReelMedia;
contentPiece: ContentPiece;
contentMedia: ContentMedia;
recording: Recording;
recordingMedia: RecordingMedia;
recordingSegment: RecordingSegment;
@ -142,6 +144,8 @@ export interface Config {
speechCut: SpeechCutSelect<false> | SpeechCutSelect<true>;
reel: ReelSelect<false> | ReelSelect<true>;
reelMedia: ReelMediaSelect<false> | ReelMediaSelect<true>;
contentPiece: ContentPieceSelect<false> | ContentPieceSelect<true>;
contentMedia: ContentMediaSelect<false> | ContentMediaSelect<true>;
recording: RecordingSelect<false> | RecordingSelect<true>;
recordingMedia: RecordingMediaSelect<false> | RecordingMediaSelect<true>;
recordingSegment: RecordingSegmentSelect<false> | RecordingSegmentSelect<true>;
@ -1149,6 +1153,129 @@ export interface ReelMedia {
focalX?: number | null;
focalY?: number | null;
}
/**
* Peças de campanha da Central de Conteúdos. O arquivo é privado; "Rascunho" não aparece na Central pública.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contentPiece".
*/
export interface ContentPiece {
id: number;
title: string;
/**
* Gerado no primeiro "Publicar" a partir do título e imutável depois. Despublicar não apaga o endereço.
*/
slug?: string | null;
/**
* Derivado do arquivo no envio; a assessoria pode ajustar (ex.: card).
*/
type: 'video' | 'foto' | 'texto' | 'audio' | 'card';
description?: string | null;
topics?:
| (
| 'saude'
| 'educacao'
| 'cultura'
| 'esporte'
| 'seguranca-publica'
| 'meio-ambiente'
| 'economia-trabalho'
| 'direitos-humanos'
| 'infraestrutura'
| 'ciencia-tecnologia'
| 'politica-instituicoes'
| 'agricultura'
| 'habitacao-cidades'
| 'comunicacao-midia'
| 'igualdade-racial'
| 'mulheres-genero'
| 'juventude'
| 'pessoa-deficiencia'
)[]
| null;
/**
* Município da campanha relacionado à peça (opcional).
*/
municipality?: (number | null) | Municipality;
/**
* Derivado do município relacionado.
*/
cityLabel?: string | null;
/**
* Território de identidade derivado do município.
*/
region?: string | null;
institution?: string | null;
pieceDate?: string | null;
/**
* Medida pelo provedor de transcrição; nunca estimada.
*/
durationSeconds?: number | null;
/**
* Transcrição do áudio/vídeo ou texto extraído. Editável pela assessoria.
*/
transcript?: string | null;
/**
* O arquivo privado; publicado, é o que a Central pública serve.
*/
media?: (number | null) | ContentMedia;
/**
* Link canônico do Instagram/YouTube quando a peça entrou por link.
*/
sourceUrl?: string | null;
origin: 'arquivo' | 'instagram' | 'youtube';
/**
* Despublicar tira a peça da Central pública na hora e preserva o arquivo.
*/
status: 'rascunho' | 'publicado';
publishedAt?: string | null;
processingStatus: 'processando' | 'pronto' | 'falhou';
/**
* Progresso honesto do processamento em andamento.
*/
step?: ('extraindo' | 'transcrevendo' | 'catalogando' | 'salvando') | null;
/**
* Motivo interno da falha; nunca vai à pessoa com o detalhe cru.
*/
error?: string | null;
/**
* Título, descrição, transcrição, temas, cidade e instituição normalizados (sem acentos, minúsculas).
*/
searchText?: string | null;
/**
* O que a assessoria já editou; a catalogação automática nunca sobrescreve estes campos.
*/
curatedFields?:
| ('title' | 'description' | 'topics' | 'municipality' | 'institution' | 'pieceDate' | 'transcript' | 'type')[]
| null;
createdBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* Arquivos das peças da Central de Conteúdos. Só abrem com login da campanha.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contentMedia".
*/
export interface ContentMedia {
id: number;
/**
* Descrição do arquivo para acessibilidade.
*/
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* Gravações próprias da equipe no acervo. O arquivo é privado; a transcrição é somente leitura.
*
@ -1878,6 +2005,14 @@ export interface PayloadLockedDocument {
relationTo: 'reelMedia';
value: number | ReelMedia;
} | null)
| ({
relationTo: 'contentPiece';
value: number | ContentPiece;
} | null)
| ({
relationTo: 'contentMedia';
value: number | ContentMedia;
} | null)
| ({
relationTo: 'recording';
value: number | Recording;
@ -2517,6 +2652,55 @@ export interface ReelMediaSelect<T extends boolean = true> {
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contentPiece_select".
*/
export interface ContentPieceSelect<T extends boolean = true> {
title?: T;
slug?: T;
type?: T;
description?: T;
topics?: T;
municipality?: T;
cityLabel?: T;
region?: T;
institution?: T;
pieceDate?: T;
durationSeconds?: T;
transcript?: T;
media?: T;
sourceUrl?: T;
origin?: T;
status?: T;
publishedAt?: T;
processingStatus?: T;
step?: T;
error?: T;
searchText?: T;
curatedFields?: T;
createdBy?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contentMedia_select".
*/
export interface ContentMediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "recording_select".
@ -3339,6 +3523,8 @@ export interface TaskCreateCollectionExport {
| 'speechCut'
| 'reel'
| 'reelMedia'
| 'contentPiece'
| 'contentMedia'
| 'recording'
| 'recordingMedia'
| 'recordingSegment'

View file

@ -19,6 +19,8 @@ import { CampaignVoteSummarySnapshot } from './collections/CampaignVoteSummarySn
import { CampaignWebAuthnCredential } from './collections/CampaignWebAuthnCredential'
import { Consent } from './collections/Consent'
import { Contact } from './collections/Contact'
import { ContentMedia } from './collections/ContentMedia'
import { ContentPiece } from './collections/ContentPiece'
import { ElectionCandidate } from './collections/ElectionCandidate'
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
import { ElectionTally } from './collections/ElectionTally'
@ -126,6 +128,8 @@ export default buildConfig({
SpeechCut,
Reel,
ReelMedia,
ContentPiece,
ContentMedia,
Recording,
RecordingMedia,
RecordingSegment,
@ -181,6 +185,8 @@ export default buildConfig({
reelMedia: true,
// C199 — private recording files; same failure mode if omitted.
recordingMedia: true,
// C211 — private content-piece files; same failure mode if omitted.
contentMedia: true,
},
bucket: mediaStorage.bucket,
config: {

View file

@ -0,0 +1,45 @@
// ---------------------------------------------------------------------------
// C211 — content pieces of the internal communication Central
// ---------------------------------------------------------------------------
import type { Access } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { getFreshCampaignUser, isPayloadAdmin } from '@/utilities/access/shared'
/**
* Readers of the content pieces: Payload admin or campaign users with the
* `communicator`, `coordinator` or `candidate` role — the same vertical gate as
* the acervo and the reels. Advisors and leaders are denied (fail-closed): the
* Central is editorial material, not a municipality-scoped staff area.
*/
export const canReadContentPiece: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}
/**
* Who may add a piece to the Central (upload, link, attach the original file):
* the same audience. Written as its own predicate, never an alias of
* `canReadContentPiece`, so a future widening of the read surface cannot
* silently grant writes.
*/
export const canCreateContentPiece: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}
/**
* Who may edit a piece's catalogue and move the kill switch. Its own predicate
* for the same reason as the create one.
*/
export const canUpdateContentPiece: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}

View file

@ -184,3 +184,9 @@ export {
canReadRecording,
canUploadRecording,
} from '@/utilities/access/recordings'
export {
canCreateContentPiece,
canReadContentPiece,
canUpdateContentPiece,
} from '@/utilities/access/contentPieces'

View file

@ -0,0 +1,266 @@
import 'server-only'
import { deepSeek } from '@ai-sdk/deepseek'
import { generateObject } from 'ai'
import type { Payload } from 'payload'
import { z } from 'zod'
import {
CONTENT_PIECE_DESCRIPTION_MAX_LENGTH,
CONTENT_PIECE_INSTITUTION_MAX_LENGTH,
CONTENT_PIECE_TITLE_MAX_LENGTH,
contentPieceTopicLabel,
contentPieceTypeLabels,
type ContentPieceType,
} from '@/lib/contentPiece'
import { institutionCatalog, institutionSpellings } from '@/lib/institutionCatalog'
import { type SpeechTopic } from '@/lib/speechFacets'
import { matchMunicipalityMentions, type SpeechFacetInput } from '@/lib/speechGazetteer'
import { normalizeForSearch } from '@/lib/speechSearch'
import { classifySpeech } from '@/utilities/speech/speechClassifier'
/**
* C211 — the automatic cataloguing of one content piece: the offline gazetteer
* for city/topics, the C153 classifier (gazetteer + validated LLM refinement)
* for the themes and one bounded DeepSeek call for title/description. Every
* step is fail-closed and never throws: a provider outage degrades to the
* deterministic fallback, and the assessoria's edits always win (the job only
* writes fields absent from `curatedFields`).
*/
const SUGGESTION_TIMEOUT_MS = 8000
const MAX_EXCERPT_CHARS = 2000
const MAX_OUTPUT_TOKENS = 500
export type ContentPieceCatalogResult = {
title?: string
description?: string
topics?: SpeechTopic[]
municipalityId?: number | null
institution?: string
source: 'ai' | 'fallback' | 'none'
}
type ContentPieceClassifier = (input: SpeechFacetInput) => Promise<{
facets: { topics: SpeechTopic[] }
}>
type ContentPieceSuggester = (input: {
type: ContentPieceType
currentTitle: string
transcript: string
cityLabel: string | null
topics: readonly SpeechTopic[]
}) => Promise<{ title: string; description: string; source: 'ai' | 'fallback' }>
const suggestionSchema = z.object({
title: z
.string()
.describe('Título curto em português (no máximo 120 caracteres), sem aspas nem prefixos.'),
description: z
.string()
.describe(
'Descrição factual em português (no máximo 600 caracteres) do que a peça mostra, sem inventar fatos.',
),
})
const SYSTEM_PROMPT =
'Você cataloga peças de campanha do deputado federal Jorge Solla (PT-BA) para a Central de Conteúdos da campanha. ' +
'Receberá o tipo da peça e a transcrição automática (ASR) do material, que pode ter ruído. ' +
'Escreva um título curto e concreto (o assunto da peça, não o nome do deputado) e uma descrição de uma a duas ' +
'frases, em português do Brasil, adequadas para apresentar o material a quem vai compartilhar no WhatsApp. ' +
'Use apenas o que está na transcrição; nunca invente números, acordos, cidades ou citações.'
const clip = (value: string, maximum: number): string => value.trim().slice(0, maximum).trim()
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/** Deterministic fallback: never empty, never invents a fact. */
export const buildContentPieceFallbackMetadata = ({
type,
currentTitle,
cityLabel,
topics,
}: {
type: ContentPieceType
currentTitle: string
cityLabel: string | null
topics: readonly SpeechTopic[]
}): { title: string; description: string; source: 'fallback' } => {
const typeLabel = contentPieceTypeLabels[type].toLowerCase()
const topicLabels = topics.slice(0, 3).map(contentPieceTopicLabel)
const parts = [
`${contentPieceTypeLabels[type]} da campanha de Jorge Solla 1313`,
cityLabel ? `em ${cityLabel}` : null,
topicLabels.length > 0 ? `sobre ${topicLabels.join(', ')}` : null,
].filter((part): part is string => part !== null)
return {
title: clip(currentTitle, CONTENT_PIECE_TITLE_MAX_LENGTH) || `Peça de ${typeLabel}`,
description: clip(`${parts.join(' ')}.`, CONTENT_PIECE_DESCRIPTION_MAX_LENGTH),
source: 'fallback',
}
}
/**
* One-shot AI title/description for the piece, on the same DeepSeek path as
* the acervo AI (C167): never throws, never blocks the job. Missing key, empty
* excerpt, timeout or unusable output all resolve to the deterministic
* fallback.
*/
const suggestContentPieceMetadata: ContentPieceSuggester = async ({
type,
currentTitle,
transcript,
cityLabel,
topics,
}) => {
const fallback = buildContentPieceFallbackMetadata({ type, currentTitle, cityLabel, topics })
const excerpt = transcript.trim().slice(0, MAX_EXCERPT_CHARS)
if (!excerpt || !process.env.DEEPSEEK_API_KEY) return fallback
try {
const { object } = await generateObject({
model: deepSeek('deepseek-flash'),
schema: suggestionSchema,
system: SYSTEM_PROMPT,
prompt: [
`Tipo: ${contentPieceTypeLabels[type]}`,
`Título atual: ${currentTitle}`,
cityLabel ? `Cidade: ${cityLabel}` : null,
`Transcrição (ASR): "${excerpt}"`,
]
.filter((line): line is string => line !== null)
.join('\n'),
temperature: 0.3,
maxOutputTokens: MAX_OUTPUT_TOKENS,
abortSignal: AbortSignal.timeout(SUGGESTION_TIMEOUT_MS),
})
const title = clip(object.title, CONTENT_PIECE_TITLE_MAX_LENGTH)
const description = clip(object.description, CONTENT_PIECE_DESCRIPTION_MAX_LENGTH)
if (!title || !description) return fallback
return { title, description, source: 'ai' }
} catch {
return fallback
}
}
/**
* The city rule is deliberately conservative: the gazetteer mention list is
* converted to distinct cities, and only a city with exactly ONE catalog entry
* resolves. Salvador has one entry per TSE zone, so a bare "Salvador" mention
* is ambiguous and stays empty — the assessoria picks the zone on the ficha.
*/
export const resolveContentPieceMunicipalityId = async ({
payload,
transcript,
}: {
payload: Payload
transcript: string
}): Promise<number | null> => {
const mentions = matchMunicipalityMentions(transcript)
const cities = new Set(mentions.map((entry) => entry.city))
if (cities.size !== 1) return null
const entries = mentions.filter((entry) => entry.city === [...cities][0])
if (entries.length !== 1) return null
const found = await payload.find({
collection: 'municipality',
where: { slug: { equals: entries[0]!.slug } },
depth: 0,
limit: 1,
pagination: false,
// Intentional admin bypass: the município catalog is read-only geography.
overrideAccess: true,
})
return found.docs[0]?.id ?? null
}
/**
* Matches the institution catalog against the text: a spelling counts only as a
* whole word (accent/case insensitive), and more than one matching institution
* is ambiguous and stays empty.
*/
export const resolveContentPieceInstitution = (transcript: string): string | undefined => {
const haystack = normalizeForSearch(transcript)
const matched = institutionCatalog.filter((entry) =>
institutionSpellings(entry).some((spelling) => {
const needle = normalizeForSearch(spelling)
return (
needle.length > 0 &&
new RegExp(`(^|[^a-z0-9])${escapeRegExp(needle)}([^a-z0-9]|$)`).test(haystack)
)
}),
)
return matched.length === 1 ? matched[0]!.name : undefined
}
/**
* The cataloguing entry point of the job. Runs the gazetteer + the C153
* classifier + the suggestion, and returns only what it is confident about.
* An empty transcript (a photo, a card, a silent video) skips every external
* call and returns `source: 'none'` — the type/title the upload derived stay.
*/
export const catalogContentPiece = async ({
payload,
type,
title,
transcript,
classify = classifySpeech,
suggest = suggestContentPieceMetadata,
}: {
payload: Payload
type: ContentPieceType
title: string
transcript: string | null
classify?: ContentPieceClassifier
suggest?: ContentPieceSuggester
}): Promise<ContentPieceCatalogResult> => {
const text = transcript?.trim() ?? ''
if (!text) return { source: 'none' }
let topics: SpeechTopic[] = []
try {
const classification = await classify({ transcript: text })
topics = [...new Set(classification.facets.topics)]
} catch {
// The classifier contract is "never throws"; if it does, the catalogue
// still gets city/institution/title from the gazetteer and the fallback.
}
const municipalityId = await resolveContentPieceMunicipalityId({ payload, transcript: text })
const municipality = municipalityId
? await payload
.findByID({
collection: 'municipality',
id: municipalityId,
depth: 0,
select: { name: true },
// Intentional admin bypass: the município catalog is read-only geography.
overrideAccess: true,
})
.catch(() => null)
: null
const suggestion = await suggest({
type,
currentTitle: title,
transcript: text,
cityLabel: municipality?.name ?? null,
topics,
})
const institution = resolveContentPieceInstitution(text)?.slice(
0,
CONTENT_PIECE_INSTITUTION_MAX_LENGTH,
)
return {
title: suggestion.title,
description: suggestion.description,
...(topics.length > 0 ? { topics } : {}),
...(municipalityId !== null ? { municipalityId } : {}),
...(institution ? { institution } : {}),
source: suggestion.source,
}
}

View file

@ -0,0 +1,337 @@
import 'server-only'
import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import {
CONTENT_MEDIA_SLUG,
CONTENT_PIECE_FAILURE_INTERRUPTED,
CONTENT_PIECE_FFMPEG_TIMEOUT_MS,
needsContentPieceProcessing,
type ContentPieceStep,
type ContentPieceType,
} from '@/lib/contentPiece'
import {
buildRecordingAudioFfmpegArgs,
mergeChunkTranscriptions,
RECORDING_AUDIO_CHUNK_SECONDS,
} from '@/lib/recordingTranscription'
import type { ContentPiece } from '@/payload-types'
import {
deepInfraTranscribeSegments,
type TranscribeSegmentsResult,
} from '@/utilities/ai/deepInfraTranscribe'
import { catalogContentPiece } from '@/utilities/content/contentPieceCataloging'
import { resolveContentPieceSource } from '@/utilities/content/contentPieceLink'
import { messageOf, runFfmpeg } from '@/utilities/media/ffmpeg'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import { downloadPrivateMediaToFile } from '@/utilities/privateMedia/privateMediaResponse'
/**
* C211 — the processing job of one content piece: resolve the source (archived
* file or the official Instagram extraction; a YouTube/unsupported link becomes
* a peça-link), extract/split the audio with ffmpeg, transcribe it and run the
* automatic cataloguing, then persist the result (`pronto`). Runs after the
* upload response (`contentPieceScheduler`) so no request waits on minutes of
* ASR; every failure leaves the row `falhou` with the file preserved and the
* retry reuses the row.
*
* The cataloguing never overwrites a field the assessoria already edited
* (`curatedFields`): the final write re-reads the row inside the transaction.
*/
/** A piece stopped mid-flight (deploy/restart) is reaped to `falhou` after this. */
export const CONTENT_PIECE_STALE_MS = 60 * 60_000
const FAILURE_FALLBACK = 'Falha ao processar a peça.'
const NO_AUDIO_MESSAGE = 'Não foi possível extrair o áudio da peça.'
type ChunkTranscriber = (
file: Blob,
options?: { filename?: string; timeoutMs?: number },
) => Promise<TranscribeSegmentsResult>
// The job runs after the upload response, with no request actor: every write
// below is an intentional admin bypass, justified because the row was created
// behind the Central gate and the pipeline owns its state.
const loadContentPieceSystem = (payload: Payload, contentPieceId: number) =>
payload.findByID({
collection: 'contentPiece',
id: contentPieceId,
depth: 1,
// Intentional admin bypass: the job reads the row it owns.
overrideAccess: true,
})
const updateContentPieceSystem = (
payload: Payload,
contentPieceId: number,
data: Partial<ContentPiece>,
req?: PayloadTransactionRequest,
) =>
payload.update({
collection: 'contentPiece',
id: contentPieceId,
data,
// Intentional admin bypass: the pipeline owns the row state.
overrideAccess: true,
...(req ? { req } : {}),
})
const failContentPiece = (
payload: Payload,
contentPieceId: number,
message: string,
step: ContentPieceStep | null = 'extraindo',
): Promise<unknown> =>
updateContentPieceSystem(payload, contentPieceId, {
processingStatus: 'falhou',
step,
error: message,
})
const staticDirOf = (payload: Payload): string => {
const upload = payload.collections[CONTENT_MEDIA_SLUG].config.upload
return upload && typeof upload === 'object' && upload.staticDir
? upload.staticDir
: CONTENT_MEDIA_SLUG
}
const chunkFilesIn = async (tempDir: string): Promise<string[]> => {
const entries = await readdir(tempDir)
return entries.filter((name) => /^chunk-\d+\.mp3$/.test(name)).sort()
}
/**
* The audio half of the pipeline: extract/split the archived media and
* transcribe every chunk. Returns the transcript and the measured duration, or
* a failure message the caller stores.
*/
const transcribeContentPieceMedia = async ({
payload,
piece,
media,
localPath,
tempDir,
transcribe,
}: {
payload: Payload
piece: { id: number }
media: { filename?: string | null; filesize?: number | null; mimeType?: string | null }
localPath: string | null
tempDir: string
transcribe: ChunkTranscriber
}): Promise<
| { transcript: string; durationSeconds: number }
| { failure: { message: string; step: ContentPieceStep } }
> => {
const inputPath = localPath ?? join(tempDir, 'source')
const outputPattern = join(tempDir, 'chunk-%03d.mp3')
if (!localPath) {
await downloadPrivateMediaToFile({
media,
staticDir: staticDirOf(payload),
destinationPath: inputPath,
})
}
await runFfmpeg(
buildRecordingAudioFfmpegArgs({ inputPath, outputPattern }),
RECORDING_AUDIO_CHUNK_SECONDS,
NO_AUDIO_MESSAGE,
CONTENT_PIECE_FFMPEG_TIMEOUT_MS,
)
const chunkFiles = await chunkFilesIn(tempDir)
if (chunkFiles.length === 0) {
return { failure: { message: NO_AUDIO_MESSAGE, step: 'extraindo' } }
}
await updateContentPieceSystem(payload, piece.id, { step: 'transcrevendo' })
const chunks: {
offsetSeconds: number
segments: { start: number; end: number; text: string }[]
}[] = []
let measured = 0
for (const [index, chunkFile] of chunkFiles.entries()) {
const bytes = await readFile(join(tempDir, chunkFile))
const result = await transcribe(new Blob([bytes], { type: 'audio/mpeg' }), {
filename: chunkFile,
})
if (!result.ok) {
return { failure: { message: result.error, step: 'transcrevendo' } }
}
chunks.push({
offsetSeconds: index * RECORDING_AUDIO_CHUNK_SECONDS,
segments: result.segments,
})
// Only what the provider measured: a guessed duration would make the ficha
// label lie.
if (result.durationSeconds !== null) measured += result.durationSeconds
// Heartbeat: a chunk takes minutes; the updatedAt keeps the reaper away.
await updateContentPieceSystem(payload, piece.id, { step: 'transcrevendo' })
}
const segments = mergeChunkTranscriptions(chunks)
if (segments.length === 0) {
return { failure: { message: FAILURE_FALLBACK, step: 'transcrevendo' } }
}
return {
transcript: segments
.map((segment) => segment.text)
.join(' ')
.trim(),
durationSeconds: Math.round(measured),
}
}
/**
* The whole pipeline of one piece. Never throws: an orphan job (server restart)
* is covered by the lazy reaper, and any error marks the row `falhou` so the
* retry reuses it.
*/
export const runContentPieceJob = async (
payload: Payload,
contentPieceId: number,
{
transcribe = deepInfraTranscribeSegments,
catalog = catalogContentPiece,
resolveSource = resolveContentPieceSource,
}: {
transcribe?: ChunkTranscriber
catalog?: typeof catalogContentPiece
resolveSource?: typeof resolveContentPieceSource
} = {},
): Promise<void> => {
let tempDir: string | null = null
let currentStep: ContentPieceStep = 'extraindo'
const markStep = async (step: ContentPieceStep): Promise<void> => {
currentStep = step
await updateContentPieceSystem(payload, contentPieceId, { step })
}
try {
const piece = await loadContentPieceSystem(payload, contentPieceId)
if (piece.processingStatus !== 'processando') return
tempDir = await mkdtemp(join(tmpdir(), 'content-piece-'))
await markStep('extraindo')
const source = await resolveSource({ payload, piece, tempDir })
const suggestedType: ContentPieceType | null = source.suggestedType ?? null
const effectiveType = suggestedType ?? piece.type
let transcript = piece.transcript?.trim() ?? ''
let durationSeconds: number | null = null
if (!transcript && source.caption) transcript = source.caption.trim()
if (needsContentPieceProcessing(effectiveType) && source.media) {
const result = await transcribeContentPieceMedia({
payload,
piece,
media: source.media,
localPath: source.localPath,
tempDir,
transcribe,
})
if ('failure' in result) {
await failContentPiece(payload, contentPieceId, result.failure.message, result.failure.step)
return
}
transcript = result.transcript
// Only what the provider measured: a zero/absent measurement never
// becomes a duration the ficha would present as real.
durationSeconds = result.durationSeconds > 0 ? result.durationSeconds : null
}
await markStep('catalogando')
const catalogued = await catalog({
payload,
type: effectiveType,
title: piece.title,
transcript: transcript || null,
})
await markStep('salvando')
await withPayloadTransaction(payload, async ({ req }) => {
// Fresh read inside the transaction: the fields the assessoria edited
// while the pipeline ran must not be overwritten (D6).
const fresh = await payload.find({
collection: 'contentPiece',
where: { id: { equals: contentPieceId } },
depth: 0,
limit: 1,
pagination: false,
select: { curatedFields: true },
req,
// Intentional admin bypass: the pipeline owns the row state.
overrideAccess: true,
})
const curated = new Set(fresh.docs[0]?.curatedFields ?? [])
const data: Partial<ContentPiece> = {
processingStatus: 'pronto',
step: null,
error: null,
...(durationSeconds !== null ? { durationSeconds } : {}),
}
if (transcript && !curated.has('transcript')) data.transcript = transcript
if (suggestedType && !curated.has('type')) data.type = suggestedType
if (catalogued.title && !curated.has('title')) data.title = catalogued.title
if (catalogued.description && !curated.has('description')) {
data.description = catalogued.description
}
if (catalogued.topics && !curated.has('topics')) data.topics = catalogued.topics
if (catalogued.municipalityId != null && !curated.has('municipality')) {
data.municipality = catalogued.municipalityId
}
if (catalogued.institution && !curated.has('institution')) {
data.institution = catalogued.institution
}
await updateContentPieceSystem(payload, contentPieceId, data, req)
})
} catch (error) {
await failContentPiece(
payload,
contentPieceId,
messageOf(error, FAILURE_FALLBACK),
currentStep,
).catch(() => undefined)
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}
}
/**
* Lazy reaper: a `processando` row untouched for `CONTENT_PIECE_STALE_MS` was
* lost to a restart (there is no queue to ask). It becomes `falhou` with the
* interruption copy, the conditional `where` never clobbers a job that just
* finished, and the retry reuses the row — intentional admin bypass, like the
* other system writes above.
*/
export const reapStaleContentPiece = async (
payload: Payload,
piece: { id: number; processingStatus: string; updatedAt: string },
): Promise<boolean> => {
if (piece.processingStatus !== 'processando') return false
const updatedAt = Date.parse(piece.updatedAt)
if (!Number.isFinite(updatedAt) || Date.now() - updatedAt < CONTENT_PIECE_STALE_MS) return false
const result = await payload.update({
collection: 'contentPiece',
where: {
and: [{ id: { equals: piece.id } }, { processingStatus: { equals: 'processando' } }],
},
data: { processingStatus: 'falhou', step: null, error: CONTENT_PIECE_FAILURE_INTERRUPTED },
// Intentional admin bypass: the reaper repairs a row the system owns.
overrideAccess: true,
})
return result.docs.length > 0
}

View file

@ -0,0 +1,193 @@
import 'server-only'
import { createWriteStream } from 'node:fs'
import { Readable, Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import type { Payload } from 'payload'
import {
CONTENT_MEDIA_SLUG,
CONTENT_PIECE_MAX_BYTES,
parseContentPieceLink,
type ContentPieceType,
} from '@/lib/contentPiece'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import {
INSTAGRAM_MAX_RESULTS_CAP,
isInstagramFeedConfigured,
loadInstagramFeed,
} from '@/utilities/socialFeed/instagramFeed'
/**
* C211 — the official-path resolution of a piece that entered by link. There is
* NO scraping and no oEmbed: the only extraction route is the Instagram Graph
* API of the campaign's OWN account (listing the profile media and matching the
* pasted permalink), exactly what the gate decided. YouTube exposes only
* metadata through the Data API, so a YouTube piece always circulates by link.
*
* A failed extraction never throws at the caller: the piece becomes a
* peça-link (the job still catalogues the official caption when there is one).
* A download that starts and fails DOES throw — the row is preserved and the
* assessoria can reprocess or attach the original file.
*/
export type ContentPieceSourceMedia = {
id: number
filename?: string | null
filesize?: number | null
mimeType?: string | null
}
export type ContentPieceSourceResolution = {
/** The archived media (existing or newly extracted); null for a peça-link. */
media: ContentPieceSourceMedia | null
/** Local temp copy of an extracted file (the job's ffmpeg input). */
localPath: string | null
/** Official caption of the matched post; null when there was no match. */
caption: string | null
/** The type the official media reveals (an Instagram image is a photo). */
suggestedType?: ContentPieceType
}
type FeedLoader = typeof loadInstagramFeed
const streamResponseToFile = async ({
response,
destination,
maxBytes,
}: {
response: Response
destination: string
maxBytes: number
}): Promise<void> => {
if (!response.body) throw new Error('A mídia extraída não trouxe arquivo.')
let received = 0
const guard = new Transform({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.length
if (received > maxBytes) {
callback(new Error('A mídia extraída excede o limite da Central.'))
return
}
callback(null, chunk)
},
})
await pipeline(
Readable.fromWeb(response.body as unknown as import('node:stream/web').ReadableStream),
guard,
createWriteStream(destination),
)
}
/**
* Resolves the source of one piece: the archived media when it exists,
* otherwise the official extraction for an Instagram link, otherwise nothing
* (peça-link). Injectable deps keep the int test free of network calls.
*/
export const resolveContentPieceSource = async ({
payload,
piece,
tempDir,
loadFeed = loadInstagramFeed,
fetchImpl = fetch,
}: {
payload: Payload
piece: {
id: number
title: string
origin: string
sourceUrl?: string | null
media?: number | ContentPieceSourceMedia | null
}
tempDir: string
loadFeed?: FeedLoader
fetchImpl?: (input: string, init?: RequestInit) => Promise<Response>
}): Promise<ContentPieceSourceResolution> => {
if (piece.media && typeof piece.media === 'object') {
return { media: piece.media, localPath: null, caption: null }
}
if (piece.media) {
// A bare id (depth 0) is not a file the pipeline can read; fail closed to
// the link resolution instead of handing a number to the downloader.
return { media: null, localPath: null, caption: null }
}
if (piece.origin !== 'instagram' || !piece.sourceUrl) {
return { media: null, localPath: null, caption: null }
}
const link = parseContentPieceLink(piece.sourceUrl)
if (!link || link.origin !== 'instagram') {
return { media: null, localPath: null, caption: null }
}
const settings = await payload.findGlobal({ slug: 'social-feed-settings', depth: 0 })
if (!isInstagramFeedConfigured(settings)) {
return { media: null, localPath: null, caption: null }
}
let feed: Awaited<ReturnType<FeedLoader>>
try {
feed = await loadFeed({
accessToken: settings.instagramAccessToken as string,
userId: settings.instagramUserId as string,
maxResults: INSTAGRAM_MAX_RESULTS_CAP,
fetchImpl,
})
} catch {
// Token expired, API down: fail closed to the link piece, never to an error
// in the assessoria's face.
return { media: null, localPath: null, caption: null }
}
// Canonical URLs on both sides: the shortcode is the identity and the form is
// the same, so two spellings of the same post always match.
const matched = feed.posts.find(
(post) => parseContentPieceLink(post.permalink)?.canonicalUrl === link.canonicalUrl,
)
if (!matched) return { media: null, localPath: null, caption: null }
const extractable =
matched.mediaType === 'IMAGE' || matched.mediaType === 'VIDEO' || matched.mediaType === 'REEL'
if (!extractable || !matched.mediaUrl) {
// Carousel (no single file) or a post without a media URL: the caption is
// still official metadata the cataloguing can use.
return { media: null, localPath: null, caption: matched.caption }
}
const response = await fetchImpl(matched.mediaUrl)
if (!response.ok) throw new Error('Não foi possível baixar a mídia do Instagram.')
const suggestedType: ContentPieceType = matched.mediaType === 'IMAGE' ? 'foto' : 'video'
const extension = suggestedType === 'foto' ? 'jpg' : 'mp4'
const localPath = `${tempDir}/instagram-${link.shortcode}.${extension}`
await streamResponseToFile({
response,
destination: localPath,
maxBytes: CONTENT_PIECE_MAX_BYTES,
})
const media = await withPayloadTransaction(payload, async ({ req }) => {
const created = await payload.create({
collection: CONTENT_MEDIA_SLUG,
data: { alt: piece.title },
filePath: localPath,
depth: 0,
// Intentional admin bypass: the extraction runs in the pipeline, after
// the response, and the piece was created behind the Central gate.
overrideAccess: true,
req,
})
await payload.update({
collection: 'contentPiece',
id: piece.id,
data: { media: created.id },
depth: 0,
// Intentional admin bypass: the pipeline owns the row state.
overrideAccess: true,
req,
})
return created
})
return { media, localPath, caption: matched.caption, suggestedType }
}

View file

@ -0,0 +1,155 @@
/**
* C211 — URL contract of the Central de Conteúdos list
* (`/campanha/comunicacao/conteudos`): search term, the three facet filters
* (tipo, processamento, publicação) and pagination, over the shared helpers.
* The canonical form drops a facet whose every option is selected — selecting
* all is the same filter as selecting none (B18's frozen contract).
*/
import type { Where } from 'payload'
import { CAMPAIGN_COMMUNICATION_CONTEUDOS } from '@/lib/campaignPaths'
import {
CONTENT_PIECE_PROCESSING_STATUSES,
CONTENT_PIECE_STATUSES,
CONTENT_PIECE_TYPES,
type ContentPieceProcessingStatus,
type ContentPieceStatus,
type ContentPieceType,
} from '@/lib/contentPiece'
import { normalizeForSearch } from '@/lib/speechSearch'
import {
buildListHref,
firstValue,
normalizedText,
parseExhaustiveEnumParam,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
export const contentPiecePageSize = 25
const TYPE_PARAM = 'type'
const STATUS_PARAM = 'status'
const PROCESSING_PARAM = 'processing'
const contentPieceListParamNames = [
TYPE_PARAM,
STATUS_PARAM,
PROCESSING_PARAM,
'q',
'page',
] as const
const contentPieceListParamNameSet = new Set<string>(contentPieceListParamNames)
const typeValues = new Set<string>(CONTENT_PIECE_TYPES)
const statusValues = new Set<string>(CONTENT_PIECE_STATUSES)
const processingValues = new Set<string>(CONTENT_PIECE_PROCESSING_STATUSES)
export type ContentPieceListState = {
page: number
q?: string
types?: ContentPieceType[]
statuses?: ContentPieceStatus[]
processing?: ContentPieceProcessingStatus[]
}
export const parseContentPieceListParams = (params: RawSearchParams): ContentPieceListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const q = normalizedText(firstValue(params.q))
const types = parseExhaustiveEnumParam<ContentPieceType>(params[TYPE_PARAM], typeValues)
const statuses = parseExhaustiveEnumParam<ContentPieceStatus>(params[STATUS_PARAM], statusValues)
const processing = parseExhaustiveEnumParam<ContentPieceProcessingStatus>(
params[PROCESSING_PARAM],
processingValues,
)
return {
page: rawPage ?? 1,
...(q ? { q } : {}),
...(types.length > 0 ? { types } : {}),
...(statuses.length > 0 ? { statuses } : {}),
...(processing.length > 0 ? { processing } : {}),
}
}
const contentPieceListSearchParams = (
state: ContentPieceListState,
page = state.page,
): URLSearchParams => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
for (const type of state.types ?? []) params.append(TYPE_PARAM, type)
for (const status of state.statuses ?? []) params.append(STATUS_PARAM, status)
for (const value of state.processing ?? []) params.append(PROCESSING_PARAM, value)
if (page > 1) params.set('page', String(page))
return params
}
export const buildContentPieceListHref = (state: ContentPieceListState, page: number): string =>
buildListHref(state, contentPieceListSearchParams, CAMPAIGN_COMMUNICATION_CONTEUDOS, page)
export const resolveContentPieceListUrl = (
params: RawSearchParams,
totalPages?: number,
): { state: ContentPieceListState; href: string; redirectHref?: string } =>
resolveListUrl({
params,
paramNameSet: contentPieceListParamNameSet,
parse: parseContentPieceListParams,
buildSearchParams: contentPieceListSearchParams,
basePath: CAMPAIGN_COMMUNICATION_CONTEUDOS,
totalPages,
})
type ContentPieceFacet = 'types' | 'statuses' | 'processing'
/** Toggles one value of a facet, resetting to page 1 and dropping an empty facet. */
const toggleFacet = <Value extends string>(
state: ContentPieceListState,
facet: ContentPieceFacet,
value: Value,
): ContentPieceListState => {
const current = (state[facet] ?? []) as readonly string[]
const next = current.includes(value)
? current.filter((item) => item !== value)
: [...current, value]
return {
...state,
page: 1,
[facet]: next.length > 0 ? next : undefined,
}
}
export const toggleContentPieceType = (
state: ContentPieceListState,
value: ContentPieceType,
): ContentPieceListState => toggleFacet(state, 'types', value)
export const toggleContentPieceStatus = (
state: ContentPieceListState,
value: ContentPieceStatus,
): ContentPieceListState => toggleFacet(state, 'statuses', value)
export const toggleContentPieceProcessing = (
state: ContentPieceListState,
value: ContentPieceProcessingStatus,
): ContentPieceListState => toggleFacet(state, 'processing', value)
export const clearContentPieceListFilters = (): ContentPieceListState => ({ page: 1 })
/**
* The list `where`: the search term ILIKEs the normalized `searchText` (the
* trigram index makes it sargable) and each selected facet narrows with `in`.
*/
export const buildContentPieceListWhere = (state: ContentPieceListState): Where => {
const conditions: Where[] = []
const q = normalizedText(state.q)
if (q) conditions.push({ searchText: { contains: normalizeForSearch(q) } })
if (state.types?.length) conditions.push({ type: { in: state.types } })
if (state.statuses?.length) conditions.push({ status: { in: state.statuses } })
if (state.processing?.length) conditions.push({ processingStatus: { in: state.processing } })
if (conditions.length === 0) return {}
return conditions.length === 1 ? conditions[0]! : { and: conditions }
}

View file

@ -0,0 +1,176 @@
import 'server-only'
import type { Payload } from 'payload'
import { toContentPieceViewModel, type ContentPieceViewModel } from '@/lib/contentPiece'
import { CONTENT_PIECE_NOT_FOUND_MESSAGE } from '@/lib/schemas/contentPiece'
import type { CampaignUser } from '@/payload-types'
import { type RawSearchParams } from '@/utilities/campaignListUrl'
import {
buildContentPieceListWhere,
contentPiecePageSize,
parseContentPieceListParams,
resolveContentPieceListUrl,
type ContentPieceListState,
} from '@/utilities/content/contentPieceListUrl'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
/**
* Lean `select` of the list: everything the row view model renders. `cityLabel`
* and `searchText` are denormalized on the row, so the list never joins.
*/
const contentPieceListSelect = {
title: true,
type: true,
status: true,
processingStatus: true,
step: true,
origin: true,
topics: true,
cityLabel: true,
region: true,
durationSeconds: true,
pieceDate: true,
publishedAt: true,
error: true,
media: true,
} as const
/** Everything the ficha renders, including the editable catalogue. */
const contentPieceDetailSelect = {
...contentPieceListSelect,
slug: true,
description: true,
transcript: true,
institution: true,
sourceUrl: true,
municipality: true,
curatedFields: true,
} as const
export const ContentPieceNotFoundError = createEntityNotFoundError(
'Content piece',
CONTENT_PIECE_NOT_FOUND_MESSAGE,
)
/**
* The município options of the ficha: the seeded, read-only geography list
* (435 rows — one per Bahia municipality, Salvador split by TSE zone), read
* once per ficha render.
*/
export const loadContentPieceFormOptions = async (
payload: Payload,
): Promise<{ municipalities: { id: number; name: string }[] }> => {
const result = await payload.find({
collection: 'municipality',
depth: 0,
limit: 500,
pagination: false,
sort: 'name',
select: { name: true },
// Intentional admin bypass: the município catalog is read-only geography.
overrideAccess: true,
})
return { municipalities: result.docs.map((doc) => ({ id: doc.id, name: doc.name })) }
}
export type ContentPieceListPageData = {
rows: ContentPieceViewModel[]
state: ContentPieceListState
redirectHref?: string
totalDocs: number
totalPages: number
}
/**
* C211 — the Central list: every piece (drafts included — the ficha is where
* the kill switch is reversed), newest first, with the search term and the
* facet filters. The render path is read-only: a stale `processando` row is
* repaired by the status poll (`getContentPieceStatusesForActor`), which the
* list's refresher calls while something is moving.
*/
export const loadContentPieceListPageData = async (
payload: Payload,
user: CampaignUser,
searchParams: Promise<RawSearchParams> | RawSearchParams,
): Promise<ContentPieceListPageData> => {
const rawSearchParams = await searchParams
const state = parseContentPieceListParams(rawSearchParams)
const where = buildContentPieceListWhere(state)
const result = await payload.find({
collection: 'contentPiece',
depth: 0,
limit: contentPiecePageSize,
page: state.page,
sort: '-createdAt',
where,
select: contentPieceListSelect,
user,
overrideAccess: false,
})
const resolvedUrl = resolveContentPieceListUrl(rawSearchParams, result.totalPages)
return {
rows: result.docs.map((doc) => toContentPieceViewModel(doc)),
state: resolvedUrl.state,
redirectHref: resolvedUrl.redirectHref,
totalDocs: result.totalDocs,
totalPages: result.totalPages,
}
}
export type ContentPieceDetailPageData = {
piece: ContentPieceViewModel & {
description: string | null
transcript: string | null
institution: string | null
sourceUrl: string | null
/** Raw `yyyy-mm-dd` for the form's date input; null when unset. */
pieceDate: string | null
municipalityId: number | null
curatedFields: string[]
}
}
/**
* C211 — one piece of the Central, any status: the ficha is where the catalogue
* is edited and the kill switch lives, so a failed or unpublished piece stays
* reachable (and its file servable) while it is not public.
*/
export const loadContentPieceDetailPageData = async (
payload: Payload,
user: CampaignUser,
contentPieceId: number,
): Promise<ContentPieceDetailPageData> => {
const result = await payload.find({
collection: 'contentPiece',
where: { id: { equals: contentPieceId } },
depth: 0,
limit: 1,
pagination: false,
select: contentPieceDetailSelect,
user,
overrideAccess: false,
})
const piece = result.docs[0]
if (!piece) throw new ContentPieceNotFoundError()
const viewModel = toContentPieceViewModel(piece)
return {
piece: {
...viewModel,
description: piece.description?.trim() || null,
transcript: piece.transcript?.trim() || null,
institution: piece.institution?.trim() || null,
sourceUrl: piece.sourceUrl?.trim() || null,
pieceDate: piece.pieceDate?.slice(0, 10) || null,
municipalityId:
typeof piece.municipality === 'number'
? piece.municipality
: (piece.municipality?.id ?? null),
curatedFields: piece.curatedFields ?? [],
},
}
}

View file

@ -0,0 +1,21 @@
import 'server-only'
import config from '@payload-config'
import { after } from 'next/server'
import { getPayload } from 'payload'
import { runContentPieceJob } from '@/utilities/content/contentPieceJob'
/**
* C211 — starts the transcription/cataloguing after the upload (or retry)
* response has been sent (`after` from `next/server`), so the request never
* waits on ffmpeg or minutes of ASR. Kept apart from the job so
* `contentPieceJob` stays importable in a plain Node test without the request
* lifecycle — the same split as the C199 recordings.
*/
export const startContentPieceJobInBackground = (contentPieceId: number): void => {
after(async () => {
const payload = await getPayload({ config })
await runContentPieceJob(payload, contentPieceId)
})
}

View file

@ -0,0 +1,294 @@
import 'server-only'
import { createWriteStream } from 'node:fs'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Readable, Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import type { Payload } from 'payload'
import {
CONTENT_MEDIA_SLUG,
CONTENT_PIECE_MAX_BYTES,
CONTENT_PIECE_TEXT_MAX_BYTES,
CONTENT_PIECE_TEXT_TOO_LARGE_MESSAGE,
contentPieceTitleFromFilename,
needsContentPieceProcessing,
sanitizeContentPieceFilename,
type ContentPieceOrigin,
type ContentPieceType,
} from '@/lib/contentPiece'
import {
CONTENT_PIECE_BODY_MISSING_MESSAGE,
CONTENT_PIECE_MEDIA_ALREADY_ATTACHED_MESSAGE,
CONTENT_PIECE_UPLOAD_TOO_LARGE_MESSAGE,
type ContentPieceUploadMetadata,
} from '@/lib/schemas/contentPiece'
import type { CampaignUser } from '@/payload-types'
import { startContentPieceJobInBackground } from '@/utilities/content/contentPieceScheduler'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
/**
* C211 — the upload half of the Central de Conteúdos flow: stream the raw
* request body to a temp file with the size ceiling enforced, then create the
* private media and the visible piece in ONE transaction and hand the row to
* the processing job. Nothing is persisted before the stream completes: a
* dropped connection leaves no phantom "Processando" row to reap.
*
* The route owns the origin/session/role gate; this module owns the write path.
*/
const toUploadTooLarge = (): Error => new Error(CONTENT_PIECE_UPLOAD_TOO_LARGE_MESSAGE)
/**
* Streams the request body to `destination`, enforcing the ceiling even when
* the request arrives chunked (a missing/lying `Content-Length` cannot fill the
* disk). Returns the received byte count.
*/
const streamBodyToFile = async ({
body,
destination,
maxBytes,
}: {
body: ReadableStream<Uint8Array>
destination: string
maxBytes: number
}): Promise<number> => {
let received = 0
const guard = new Transform({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.length
if (received > maxBytes) {
callback(toUploadTooLarge())
return
}
callback(null, chunk)
},
})
await pipeline(
Readable.fromWeb(body as unknown as import('node:stream/web').ReadableStream),
guard,
createWriteStream(destination),
)
return received
}
type CreatePieceInput = {
title: string
type: ContentPieceType
origin: ContentPieceOrigin
mediaId: number | null
sourceUrl?: string | null
}
const createPiece = async ({
payload,
actor,
input,
filePath,
req,
}: {
payload: Payload
actor: CampaignUser
input: CreatePieceInput
filePath?: string
req?: Parameters<typeof payload.create>[0]['req']
}) => {
let mediaId = input.mediaId
if (filePath) {
const media = await payload.create({
collection: CONTENT_MEDIA_SLUG,
data: { alt: input.title },
filePath,
depth: 0,
user: actor,
overrideAccess: false,
...(req ? { req } : {}),
})
mediaId = media.id
}
return payload.create({
collection: 'contentPiece',
data: {
title: input.title,
type: input.type,
origin: input.origin,
status: 'rascunho',
processingStatus: needsContentPieceProcessing(input.type) ? 'processando' : 'pronto',
...(needsContentPieceProcessing(input.type) ? { step: 'extraindo' } : {}),
...(mediaId !== null ? { media: mediaId } : {}),
...(input.sourceUrl ? { sourceUrl: input.sourceUrl } : {}),
},
depth: 0,
user: actor,
overrideAccess: false,
...(req ? { req } : {}),
})
}
/**
* Receives one uploaded file: streams it to a temp file, creates the private
* media and the piece (status `processando`, step `extraindo`) in one
* transaction and schedules the job. A text file is read into the transcript
* at upload time (the extraction) and the file is archived like any other —
* the ficha serves it and "despublicar preserva o arquivo" holds for text too.
*/
export const receiveContentPieceUpload = async ({
payload,
actor,
type,
metadata,
body,
contentLength,
startJob = startContentPieceJobInBackground,
}: {
payload: Payload
actor: CampaignUser
type: ContentPieceType
metadata: ContentPieceUploadMetadata
body: ReadableStream<Uint8Array> | null
contentLength: number | null
/** Injectable for tests; the route uses the `after()`-based default. */
startJob?: (contentPieceId: number) => void
}): Promise<{ id: number }> => {
if (contentLength !== null && contentLength > CONTENT_PIECE_MAX_BYTES) {
throw new Error(CONTENT_PIECE_UPLOAD_TOO_LARGE_MESSAGE)
}
if (!body) throw new Error(CONTENT_PIECE_BODY_MISSING_MESSAGE)
const title = contentPieceTitleFromFilename(metadata.filename)
const startJobFor = (pieceId: number, pieceType: ContentPieceType): void => {
if (needsContentPieceProcessing(pieceType)) startJob(pieceId)
}
let tempDir: string | null = null
try {
tempDir = await mkdtemp(join(tmpdir(), 'content-piece-'))
const uploadName = sanitizeContentPieceFilename(metadata.filename)
const tempPath = join(tempDir, uploadName)
const bytes = await streamBodyToFile({
body,
destination: tempPath,
maxBytes: CONTENT_PIECE_MAX_BYTES,
})
// A text piece carries its content in the transcript (the extraction); the
// file is archived all the same, so the ficha serves it and unpublishing
// preserves it like any other piece.
let transcript: string | null = null
if (type === 'texto') {
if (bytes > CONTENT_PIECE_TEXT_MAX_BYTES)
throw new Error(CONTENT_PIECE_TEXT_TOO_LARGE_MESSAGE)
transcript = await readFile(tempPath, 'utf8')
}
const piece = await withPayloadTransaction(payload, async ({ req }) => {
const created = await createPiece({
payload,
actor,
input: {
title,
type,
origin: 'arquivo',
mediaId: null,
sourceUrl: null,
},
filePath: tempPath,
req,
})
if (transcript !== null) {
await payload.update({
collection: 'contentPiece',
id: created.id,
data: { transcript },
depth: 0,
user: actor,
overrideAccess: false,
req,
})
}
return created
})
startJobFor(piece.id, type)
return { id: piece.id }
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}
}
/**
* Attaches the original file to a link piece (the "Anexar arquivo original"
* action): the platform offered no official path, so the assessoria supplies
* the file — the piece then runs the same processing as an upload. The guard is
* here and not only in the route, so no caller can replace an archived original
* in silence.
*/
export const attachContentPieceMedia = async ({
payload,
actor,
piece,
filename,
body,
contentLength,
startJob = startContentPieceJobInBackground,
}: {
payload: Payload
actor: CampaignUser
piece: { id: number; title: string; type: ContentPieceType; media?: number | null }
/** The uploaded file's name — the archived media keeps its extension. */
filename: string
body: ReadableStream<Uint8Array> | null
contentLength: number | null
startJob?: (contentPieceId: number) => void
}): Promise<{ id: number }> => {
if (piece.media) throw new Error(CONTENT_PIECE_MEDIA_ALREADY_ATTACHED_MESSAGE)
if (contentLength !== null && contentLength > CONTENT_PIECE_MAX_BYTES) {
throw new Error(CONTENT_PIECE_UPLOAD_TOO_LARGE_MESSAGE)
}
if (!body) throw new Error(CONTENT_PIECE_BODY_MISSING_MESSAGE)
const processing = needsContentPieceProcessing(piece.type)
let tempDir: string | null = null
try {
tempDir = await mkdtemp(join(tmpdir(), 'content-piece-attach-'))
const uploadName = sanitizeContentPieceFilename(filename)
const tempPath = join(tempDir, uploadName)
await streamBodyToFile({ body, destination: tempPath, maxBytes: CONTENT_PIECE_MAX_BYTES })
await withPayloadTransaction(payload, async ({ req }) => {
const media = await payload.create({
collection: CONTENT_MEDIA_SLUG,
data: { alt: piece.title },
filePath: tempPath,
depth: 0,
user: actor,
overrideAccess: false,
req,
})
await payload.update({
collection: 'contentPiece',
id: piece.id,
data: {
media: media.id,
processingStatus: processing ? 'processando' : 'pronto',
...(processing ? { step: 'extraindo' } : {}),
error: null,
},
depth: 0,
user: actor,
overrideAccess: false,
req,
})
})
if (processing) startJob(piece.id)
return { id: piece.id }
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}
}

View file

@ -17,19 +17,38 @@ export const getDocumentCacheTag = <Slug extends Collection>(
id: string | number,
) => `document_${collection}:${id}`
/**
* `revalidateTag` throws Next's "static generation store missing" invariant when
* a Local API write happens OUTSIDE a request scope — e2e fixtures, one-off
* scripts, seeds. There is no cache to bust in that case, so that one invariant
* is swallowed; every other failure still propagates (a silently broken
* revalidation inside a request would be a real bug).
*/
const revalidateTagSafely = (tag: string): void => {
try {
revalidateTag(tag)
} catch (error) {
if (error instanceof Error && error.message.includes('static generation store missing')) return
throw error
}
}
export const revalidateDocumentById = <Slug extends Collection>(
collection: Slug,
id: string | number,
) => revalidateTag(getDocumentCacheTag(collection, id))
) => revalidateTagSafely(getDocumentCacheTag(collection, id))
export const getCollectionListingTag = <Slug extends Collection>(collection: Slug) =>
`${collection}s`
const revalidateCollectionListing = <Slug extends Collection>(collection: Slug) =>
revalidateTag(getCollectionListingTag(collection))
revalidateTagSafely(getCollectionListingTag(collection))
export const revalidatePostsListing = () => revalidateCollectionListing('post')
export const revalidateShareLinksListing = () => revalidateCollectionListing('shareLink')
export const revalidateJinglesListing = () => revalidateCollectionListing('jingle')
/** C211 — the content pieces listing tag the public Central (S27) will read. */
export const revalidateContentPiecesListing = () => revalidateCollectionListing('contentPiece')

View file

@ -16,6 +16,8 @@ export type InstagramPost = {
caption: string | null
mediaType: InstagramMediaType
permalink: string
/** The post's own file URL (image or mp4); null for carousels/thumbnails only. */
mediaUrl: string | null
thumbnailUrl?: string
timestamp: string
}
@ -160,6 +162,7 @@ export const parseInstagramMediaResponse = (json: unknown): InstagramPost[] => {
id?: unknown
caption?: unknown
media_type?: unknown
media_url?: unknown
permalink?: unknown
timestamp?: unknown
}
@ -173,6 +176,8 @@ export const parseInstagramMediaResponse = (json: unknown): InstagramPost[] => {
mediaType: (rawItem.media_type as InstagramMediaType) ?? 'IMAGE',
permalink: rawItem.permalink,
timestamp: rawItem.timestamp,
mediaUrl:
typeof rawItem.media_url === 'string' && rawItem.media_url ? rawItem.media_url : null,
thumbnailUrl: pickInstagramThumbnail(rawItem),
})
}

View file

@ -871,4 +871,255 @@ test.describe('communication vertical (C154/C162)', () => {
expect(html).not.toContain('A separação por falante pode estar imprecisa')
})
})
test.describe('content pieces (C211)', () => {
const createPiece = async (
campaign: { fixtures: CampaignE2EOwnership },
input: {
marker: string
status?: 'rascunho' | 'publicado'
processingStatus?: 'processando' | 'pronto' | 'falhou'
type?: 'video' | 'foto' | 'texto' | 'audio' | 'card'
origin?: 'arquivo' | 'instagram' | 'youtube'
withMedia?: boolean
sourceUrl?: string
},
) => {
const bytes = Buffer.from(`content-piece-${input.marker}`)
const media =
input.withMedia === false
? null
: await campaign.fixtures.payload.create({
collection: 'contentMedia',
data: { alt: `Arquivo ${input.marker}` },
file: {
data: bytes,
mimetype: 'video/mp4',
name: `${input.marker}.mp4`,
size: bytes.length,
},
})
const piece = await campaign.fixtures.payload.create({
collection: 'contentPiece',
data: {
title: `Peça ${input.marker}`,
type: input.type ?? 'video',
status: input.status ?? 'rascunho',
processingStatus: input.processingStatus ?? 'pronto',
origin: input.origin ?? 'arquivo',
...(media ? { media: media.id } : {}),
...(input.sourceUrl ? { sourceUrl: input.sourceUrl } : {}),
},
depth: 0,
})
return { piece, media, bytes }
}
test('the list renders the row, the states and the search', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('peca')
const { piece } = await createPiece(campaign, { marker, status: 'publicado' })
const failed = await createPiece(campaign, {
marker: campaign.fixtures.value('pecafalhou'),
processingStatus: 'falhou',
})
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const response = await request.get(`/campanha/comunicacao/conteudos?q=${marker}`)
expect(response.status()).toBe(200)
const html = rendered(await response.text())
expect(html).toContain('Conteúdos')
expect(html).toContain('Enviar peças')
expect(html).toContain('Adicionar por link')
expect(html).toContain(`Peça ${marker}`)
expect(html).toContain('Pronto')
expect(html).toContain('Publicado')
expect(html).toContain(`/campanha/comunicacao/conteudos/${piece.id}`)
expect(html).toContain('aria-current="page"')
// A failed piece offers the retry action in the list.
const failedResponse = await request.get(`/campanha/comunicacao/conteudos?processing=falhou`)
const failedHtml = rendered(await failedResponse.text())
expect(failedHtml).toContain(`Peça ${failed.piece.title.replace('Peça ', '')}`)
expect(failedHtml).toContain('Reprocessar')
const miss = await request.get('/campanha/comunicacao/conteudos?q=zzzznada')
expect(rendered(await miss.text())).toContain('Nenhuma peça na Central ainda')
})
test('the ficha renders the catalogue and moves the kill switch', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('pecaficha')
const { piece } = await createPiece(campaign, { marker })
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const response = await request.get(`/campanha/comunicacao/conteudos/${piece.id}`)
expect(response.status()).toBe(200)
const html = rendered(await response.text())
expect(html).toContain(`Peça ${marker}`)
expect(html).toContain('Publicar')
expect(html).toContain('Transcrição / texto')
expect(html).toContain('Sem cidade')
expect(html).toContain(`/campanha/comunicacao/conteudos/${piece.id}/arquivo`)
const publish = await request.post(`/campanha/comunicacao/conteudos/${piece.id}/publicacao`, {
data: { contentPieceId: piece.id, published: true },
})
expect(publish.status()).toBe(200)
expect(((await publish.json()) as { piece: { status: string } }).piece.status).toBe(
'publicado',
)
const published = await campaign.fixtures.payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
})
expect(published.slug).toBe(`peca-${marker.toLowerCase()}`)
const unpublished = await request.post(
`/campanha/comunicacao/conteudos/${piece.id}/publicacao`,
{ data: { contentPieceId: piece.id, published: false } },
)
expect(unpublished.status()).toBe(200)
const afterUnpublish = await campaign.fixtures.payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
})
// The kill switch preserves the file and the public URL.
expect(afterUnpublish.status).toBe('rascunho')
expect(afterUnpublish.slug).toBe(`peca-${marker.toLowerCase()}`)
expect(afterUnpublish.media).toBeTruthy()
})
test('the private file answers only the Central roles, with range and download', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('pecafile')
const { piece, bytes } = await createPiece(campaign, { marker })
const communicator = await campaign.fixtures.createCampaignUser('communicator')
const communicatorRequest = await campaignRequest(communicator, communicator.password)
const full = await communicatorRequest.get(
`/campanha/comunicacao/conteudos/${piece.id}/arquivo`,
)
expect(full.status()).toBe(200)
expect(full.headers()['content-type']).toBe('video/mp4')
expect(Buffer.from(await full.body())).toEqual(bytes)
const partial = await communicatorRequest.get(
`/campanha/comunicacao/conteudos/${piece.id}/arquivo`,
{ headers: { Range: 'bytes=0-3' } },
)
expect(partial.status()).toBe(206)
const download = await communicatorRequest.get(
`/campanha/comunicacao/conteudos/${piece.id}/arquivo?download=1`,
)
expect(download.headers()['content-disposition']).toContain('attachment')
const advisor = await campaign.fixtures.createCampaignUser('advisor')
const advisorRequest = await campaignRequest(advisor, advisor.password)
expect(
(await advisorRequest.get(`/campanha/comunicacao/conteudos/${piece.id}/arquivo`)).status(),
).toBe(404)
const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
try {
expect(
(await anonymous.get(`/campanha/comunicacao/conteudos/${piece.id}/arquivo`)).status(),
).toBe(404)
} finally {
await anonymous.dispose()
}
})
test('the upload and link routes refuse cross-origin and advisor actors', async ({
campaign,
campaignRequest,
}) => {
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const uploadUrl = '/campanha/comunicacao/conteudos/enviar?filename=t.mp4'
expect(
(await request.post(uploadUrl, { headers: { Origin: 'https://evil.example' } })).status(),
).toBe(403)
expect(
(
await request.post('/campanha/comunicacao/conteudos/link', {
headers: { Origin: 'https://evil.example' },
data: { url: 'https://youtu.be/VIDEO1' },
})
).status(),
).toBe(403)
const advisor = await campaign.fixtures.createCampaignUser('advisor')
const advisorRequest = await campaignRequest(advisor, advisor.password)
expect((await advisorRequest.post(uploadUrl)).status()).toBe(403)
// The JSON route maps the domain refusal through the shared ladder: the
// safe message reaches the client as a 400 (same as the cut routes).
const deniedLink = await advisorRequest.post('/campanha/comunicacao/conteudos/link', {
data: { url: 'https://youtu.be/VIDEO1' },
})
expect(deniedLink.status()).toBe(400)
expect(((await deniedLink.json()) as { message: string }).message).toContain(
'não tem acesso à Central de Conteúdos',
)
})
test('the link route creates a peça-link and refuses the duplicate', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('pecalink')
const url = `https://www.instagram.com/reel/${marker}/`
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const created = await request.post('/campanha/comunicacao/conteudos/link', {
data: { url: `${url}?igsh=abc` },
})
expect(created.status()).toBe(200)
const body = (await created.json()) as { piece: { id: number; origin: string } }
expect(body.piece.origin).toBe('instagram')
const row = await campaign.fixtures.payload.findByID({
collection: 'contentPiece',
id: body.piece.id,
depth: 0,
})
expect(row.sourceUrl).toBe(url)
const duplicate = await request.post('/campanha/comunicacao/conteudos/link', {
data: { url },
})
expect(duplicate.status()).toBe(400)
expect(((await duplicate.json()) as { message: string }).message).toContain(
'já está na Central',
)
const invalid = await request.post('/campanha/comunicacao/conteudos/link', {
data: { url: 'https://twitter.com/x/status/1' },
})
expect(invalid.status()).toBe(400)
expect(((await invalid.json()) as { message: string }).message).toContain(
'Cole um link do Instagram ou do YouTube',
)
})
})
})

View file

@ -0,0 +1,787 @@
// @vitest-environment node
import { fileURLToPath } from 'node:url'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: () => undefined,
unstable_cache: (fn: unknown) => fn,
}))
vi.mock('next/server', async (importOriginal) => {
const actual = await importOriginal<typeof import('next/server')>()
// The actions start the job with `after()`, which requires a request scope;
// the job itself is exercised directly below.
return { ...actual, after: () => undefined }
})
const { getCampaignUserMock } = vi.hoisted(() => ({ getCampaignUserMock: vi.fn() }))
vi.mock('@/utilities/campaignAuth', () => ({
getCampaignUser: getCampaignUserMock,
}))
import { GET } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/arquivo/route'
import {
addContentPieceByLinkForActor,
getContentPieceStatusesForActor,
retryContentPieceForActor,
setContentPiecePublishedForActor,
updateContentPieceForActor,
} from '@/app/(campaign)/campanha/actions/contentPieces'
import type { ContentPieceCuratedField } from '@/lib/contentPiece'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
} from '@/lib/schemas/contentPiece'
import type { SpeechTopic } from '@/lib/speechFacets'
import { normalizeForSearch } from '@/lib/speechSearch'
import type { CampaignUser, ContentMedia } from '@/payload-types'
import config from '@/payload.config'
import {
CONTENT_PIECE_STALE_MS,
reapStaleContentPiece,
runContentPieceJob,
} from '@/utilities/content/contentPieceJob'
import { loadContentPieceListPageData } from '@/utilities/content/contentPiecePageData'
import {
attachContentPieceMedia,
receiveContentPieceUpload,
} from '@/utilities/content/contentPieceUpload'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
const FAKE_FFMPEG = fileURLToPath(new URL('../fixtures/fake-ffmpeg.mjs', import.meta.url))
const VIDEO_BYTES = Buffer.from('fixture-content-piece-bytes')
let payload: Payload
const createdPieceIds = new Set<number>()
const createdMediaIds = new Set<number>()
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const withEnv = (key: string, value: string | undefined): (() => void) => {
const previous = process.env[key]
if (value === undefined) delete process.env[key]
else process.env[key] = value
return () => {
if (previous === undefined) delete process.env[key]
else process.env[key] = previous
}
}
const createMedia = async (
name: string,
data: Buffer = VIDEO_BYTES,
mimetype = 'video/mp4',
): Promise<ContentMedia> => {
const media = await payload.create({
collection: 'contentMedia',
data: { alt: `Arquivo ${name}` },
file: { data, mimetype, name, size: data.length },
overrideAccess: true,
})
createdMediaIds.add(media.id)
return media
}
const createPiece = async ({
title = 'Fim da escala 6x1',
type = 'video',
status = 'rascunho',
processingStatus = 'pronto',
origin = 'arquivo',
withMedia = true,
transcript,
topics,
institution,
sourceUrl,
curatedFields,
}: {
title?: string
type?: 'video' | 'foto' | 'texto' | 'audio' | 'card'
status?: 'rascunho' | 'publicado'
processingStatus?: 'processando' | 'pronto' | 'falhou'
origin?: 'arquivo' | 'instagram' | 'youtube'
withMedia?: boolean
transcript?: string
topics?: SpeechTopic[]
institution?: string
sourceUrl?: string
curatedFields?: ContentPieceCuratedField[]
} = {}) => {
const media = withMedia ? await createMedia(`${title}.mp4`) : null
const piece = await payload.create({
collection: 'contentPiece',
data: {
title,
type,
status,
processingStatus,
origin,
...(media ? { media: media.id } : {}),
...(transcript ? { transcript } : {}),
...(topics ? { topics } : {}),
...(institution ? { institution } : {}),
...(sourceUrl ? { sourceUrl } : {}),
...(curatedFields ? { curatedFields } : {}),
},
overrideAccess: true,
})
createdPieceIds.add(piece.id)
return { piece, media }
}
const callFileRoute = async ({
pieceId,
user,
range,
download,
}: {
pieceId: number
user?: CampaignUser | null
range?: string
download?: boolean
}): Promise<Response> => {
getCampaignUserMock.mockResolvedValue(user ?? null)
const request = new Request(
`http://localhost/campanha/comunicacao/conteudos/${pieceId}/arquivo${download ? '?download=1' : ''}`,
{ headers: range ? { range } : undefined },
)
return GET(request, { params: Promise.resolve({ id: String(pieceId) }) })
}
const transcribeOk = async () => ({
ok: true as const,
segments: [
{ start: 1, end: 4, text: 'A redução da jornada é saúde' },
{ start: 5, end: 9, text: 'em Feira de Santana' },
],
durationSeconds: 134,
})
const catalogStub = async () => ({
title: 'Fim da escala 6x1 é saúde',
description: 'Solla explica a jornada.',
topics: ['saude'] as SpeechTopic[],
source: 'ai' as const,
})
describe('content pieces (C211)', () => {
let communicator: CampaignUser
let coordinator: CampaignUser
let candidate: CampaignUser
let advisor: CampaignUser
let leader: CampaignUser
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
beforeEach(async () => {
const fixtures = campaignFixtures()
;[communicator, coordinator, candidate, advisor, leader] = await Promise.all([
fixtures.createCampaignUser('communicator'),
fixtures.createCampaignUser('coordinator'),
fixtures.createCampaignUser('candidate'),
fixtures.createCampaignUser('advisor'),
fixtures.createCampaignUser('leader'),
])
getCampaignUserMock.mockReset()
})
afterAll(async () => {
for (const id of createdPieceIds) {
await payload
.delete({ collection: 'contentPiece', id, overrideAccess: true })
.catch(() => undefined)
}
for (const id of createdMediaIds) {
await payload
.delete({ collection: 'contentMedia', id, overrideAccess: true })
.catch(() => undefined)
}
})
it('reads pieces only with the communication roles', async () => {
const { piece } = await createPiece()
const readAs = (user?: CampaignUser) =>
payload.find({
collection: 'contentPiece',
where: { id: { equals: piece.id } },
depth: 0,
limit: 1,
...(user ? { user } : {}),
overrideAccess: false,
})
await expect(readAs()).rejects.toThrow()
await expect(readAs(advisor)).rejects.toThrow()
await expect(readAs(leader)).rejects.toThrow()
expect((await readAs(communicator)).docs).toHaveLength(1)
expect((await readAs(coordinator)).docs).toHaveLength(1)
expect((await readAs(candidate)).docs).toHaveLength(1)
})
it('serves the private file to the communication roles and 404s everyone else', async () => {
const { piece } = await createPiece()
const response = await callFileRoute({ pieceId: piece.id, user: communicator })
expect(response.status).toBe(200)
expect(Buffer.from(await response.arrayBuffer())).toEqual(VIDEO_BYTES)
const partial = await callFileRoute({
pieceId: piece.id,
user: coordinator,
range: 'bytes=0-3',
})
expect(partial.status).toBe(206)
const download = await callFileRoute({ pieceId: piece.id, user: candidate, download: true })
expect(download.headers.get('Content-Disposition')).toContain('attachment')
for (const user of [undefined, advisor, leader]) {
expect((await callFileRoute({ pieceId: piece.id, user })).status).toBe(404)
}
})
it('streams an upload to the private media and schedules the job only when needed', async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(Buffer.from('raw-body-piece'))
controller.close()
},
})
const scheduled: number[] = []
const { id } = await receiveContentPieceUpload({
payload,
actor: communicator,
type: 'video',
metadata: { filename: 'fim da escala.mp4' },
body,
contentLength: 13,
startJob: (pieceId) => scheduled.push(pieceId),
})
createdPieceIds.add(id)
const piece = await payload.findByID({
collection: 'contentPiece',
id,
depth: 1,
overrideAccess: true,
})
expect(piece.title).toBe('fim da escala')
expect(piece.processingStatus).toBe('processando')
expect(piece.step).toBe('extraindo')
expect(piece.status).toBe('rascunho')
expect(piece.origin).toBe('arquivo')
expect(typeof piece.media === 'object' ? piece.media?.filename : null).toBeTruthy()
expect(scheduled).toEqual([id])
const photoBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(Buffer.from('image-bytes'))
controller.close()
},
})
const { id: photoId } = await receiveContentPieceUpload({
payload,
actor: communicator,
type: 'foto',
metadata: { filename: 'card-feira.png' },
body: photoBody,
contentLength: 11,
startJob: (pieceId) => scheduled.push(pieceId),
})
createdPieceIds.add(photoId)
const photo = await payload.findByID({
collection: 'contentPiece',
id: photoId,
depth: 0,
overrideAccess: true,
})
expect(photo.processingStatus).toBe('pronto')
expect(photo.step).toBeNull()
// A photo is catalogued by the assessoria: no job was scheduled for it.
expect(scheduled).toEqual([id])
})
it('extracts a text piece without scheduling a transcription', async () => {
const text = 'Mensagem para as lideranças: peça voto pra Solla 1313.'
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(Buffer.from(text))
controller.close()
},
})
const scheduled: number[] = []
const { id } = await receiveContentPieceUpload({
payload,
actor: communicator,
type: 'texto',
metadata: { filename: 'mensagem.txt' },
body,
contentLength: Buffer.byteLength(text),
startJob: (pieceId) => scheduled.push(pieceId),
})
createdPieceIds.add(id)
const piece = await payload.findByID({
collection: 'contentPiece',
id,
depth: 0,
overrideAccess: true,
})
expect(piece.transcript).toBe(text)
expect(piece.processingStatus).toBe('processando')
expect(scheduled).toEqual([id])
})
it('attaches the original file to a link piece and refuses a second attachment', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const piece = await addContentPieceByLinkForActor({
url: 'https://www.youtube.com/watch?v=ATTACH1',
})
createdPieceIds.add(piece.id)
const bytes = Buffer.from('original-upload-bytes')
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
})
const scheduled: number[] = []
await attachContentPieceMedia({
payload,
actor: communicator,
piece: { id: piece.id, title: 'Peça por link', type: 'video' },
filename: 'original do grupo.mp4',
body,
contentLength: bytes.length,
startJob: (pieceId) => scheduled.push(pieceId),
})
const attached = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 1,
overrideAccess: true,
})
expect(attached.processingStatus).toBe('processando')
expect(attached.step).toBe('extraindo')
expect(scheduled).toEqual([piece.id])
const media = typeof attached.media === 'object' ? attached.media : null
// Payload suffixes a name already on disk (`-1`), so the assertion is the
// sanitized stem plus the preserved extension, not an exact run-dependent name.
expect(media?.filename).toMatch(/^original_do_grupo(-\d+)?\.mp4$/)
expect(media?.filesize).toBe(bytes.length)
// A second attachment is refused: the archived original is not replaced.
await expect(
attachContentPieceMedia({
payload,
actor: communicator,
piece: { id: piece.id, title: 'Peça por link', type: 'video', media: media?.id ?? null },
filename: 'outro.mp4',
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
}),
contentLength: bytes.length,
startJob: () => undefined,
}),
).rejects.toThrow()
})
it('runs the video pipeline to ready with transcript, duration, city and search text', async () => {
const { piece } = await createPiece({
processingStatus: 'processando',
title: 'Visita ao posto',
})
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
const restoreSegments = withEnv('FAKE_FFMPEG_SEGMENTS', '1')
try {
await runContentPieceJob(payload, piece.id, {
transcribe: transcribeOk,
catalog: catalogStub,
})
} finally {
restoreSegments()
restoreFfmpeg()
}
const updated = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
overrideAccess: true,
})
expect(updated.processingStatus).toBe('pronto')
expect(updated.step).toBeNull()
expect(updated.error).toBeNull()
expect(updated.durationSeconds).toBe(134)
expect(updated.transcript).toContain('A redução da jornada é saúde')
expect(updated.title).toBe('Fim da escala 6x1 é saúde')
expect(updated.description).toBe('Solla explica a jornada.')
expect(updated.topics).toEqual(['saude'])
// The derive hook owns the search text: title + description + transcript.
expect(updated.searchText).toContain('fim da escala 6x1 e saude')
expect(updated.searchText).toContain('a reducao da jornada e saude')
// The file is preserved after processing.
expect(updated.media).toBeTruthy()
})
it('marks a transcription failure as failed, preserves the file and retries', async () => {
const { piece } = await createPiece({
processingStatus: 'processando',
title: 'Áudio do grupo',
type: 'audio',
})
getCampaignUserMock.mockResolvedValue(communicator)
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
await runContentPieceJob(payload, piece.id, {
transcribe: async () => ({ ok: false as const, error: 'provider fora', status: 502 }),
catalog: catalogStub,
})
} finally {
restore()
}
const failed = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
overrideAccess: true,
})
expect(failed.processingStatus).toBe('falhou')
expect(failed.step).toBe('transcrevendo')
expect(failed.error).toBe('provider fora')
expect(failed.media).toBeTruthy()
const retried = await retryContentPieceForActor({ contentPieceId: piece.id })
expect(retried.processingStatus).toBe('processando')
// A second retry on a piece already processing is refused.
await expect(retryContentPieceForActor({ contentPieceId: piece.id })).rejects.toThrow(
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
)
})
it('refuses a retry from a denied role', async () => {
const { piece } = await createPiece({ processingStatus: 'falhou' })
getCampaignUserMock.mockResolvedValue(advisor)
await expect(retryContentPieceForActor({ contentPieceId: piece.id })).rejects.toThrow(
CONTENT_PIECE_FORBIDDEN_MESSAGE,
)
})
it('reaps a stale processing row to failed and reports the repaired status', async () => {
const { piece } = await createPiece({ processingStatus: 'processando' })
await payload.update({
collection: 'contentPiece',
id: piece.id,
data: { updatedAt: new Date(Date.now() - CONTENT_PIECE_STALE_MS - 60_000).toISOString() },
overrideAccess: true,
})
const reaped = await reapStaleContentPiece(payload, {
id: piece.id,
processingStatus: 'processando',
updatedAt: new Date(Date.now() - CONTENT_PIECE_STALE_MS - 60_000).toISOString(),
})
expect(reaped).toBe(true)
getCampaignUserMock.mockResolvedValue(communicator)
const statuses = await getContentPieceStatusesForActor({ contentPieceIds: [piece.id] })
expect(statuses[0]?.processingStatus).toBe('falhou')
expect(statuses[0]?.failureMessage).toContain('interrompido')
})
it('protects a curated title from the pipeline and clears the error on publish', async () => {
const { piece } = await createPiece({
processingStatus: 'processando',
title: 'Título da assessoria',
curatedFields: ['title'],
})
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
await runContentPieceJob(payload, piece.id, {
transcribe: transcribeOk,
catalog: catalogStub,
})
} finally {
restore()
}
const updated = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
overrideAccess: true,
})
expect(updated.title).toBe('Título da assessoria')
expect(updated.description).toBe('Solla explica a jornada.')
})
it('generates the slug on the first publish, suffixes collisions and preserves it', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const first = await createPiece({ title: 'Fim da escala 6x1' })
const second = await createPiece({ title: 'Fim da escala 6x1' })
const published = await setContentPiecePublishedForActor({
contentPieceId: first.piece.id,
published: true,
})
expect(published.status).toBe('publicado')
expect(published.publishedAtLabel).toBeTruthy()
const publishedSecond = await setContentPiecePublishedForActor({
contentPieceId: second.piece.id,
published: true,
})
const firstRow = await payload.findByID({
collection: 'contentPiece',
id: first.piece.id,
depth: 0,
overrideAccess: true,
})
const secondRow = await payload.findByID({
collection: 'contentPiece',
id: second.piece.id,
depth: 0,
overrideAccess: true,
})
expect(firstRow.slug).toBe('fim-da-escala-6x1')
expect(secondRow.slug).toBe('fim-da-escala-6x1-2')
expect(publishedSecond.isPublished).toBe(true)
const unpublished = await setContentPiecePublishedForActor({
contentPieceId: first.piece.id,
published: false,
})
expect(unpublished.status).toBe('rascunho')
const afterUnpublish = await payload.findByID({
collection: 'contentPiece',
id: first.piece.id,
depth: 0,
overrideAccess: true,
})
expect(afterUnpublish.slug).toBe('fim-da-escala-6x1')
expect(afterUnpublish.publishedAt).toBe(firstRow.publishedAt)
// A rename after the first publication never moves the public URL.
await payload.update({
collection: 'contentPiece',
id: first.piece.id,
data: { title: 'Outro título' },
overrideAccess: true,
})
const republished = await setContentPiecePublishedForActor({
contentPieceId: first.piece.id,
published: true,
})
expect(republished.status).toBe('publicado')
const renamed = await payload.findByID({
collection: 'contentPiece',
id: first.piece.id,
depth: 0,
overrideAccess: true,
})
expect(renamed.slug).toBe('fim-da-escala-6x1')
})
it('saves the ficha and records the curated fields', async () => {
const { piece } = await createPiece({ title: 'Peça antiga' })
getCampaignUserMock.mockResolvedValue(communicator)
const updated = await updateContentPieceForActor({
contentPieceId: piece.id,
title: 'Título curado',
description: 'Descrição da assessoria',
type: 'card',
pieceDate: '2026-09-10',
topics: ['saude', 'nao-existe'],
municipalityId: null,
institution: 'Câmara dos Deputados',
transcript: 'Texto revisado',
})
expect(updated.title).toBe('Título curado')
expect(updated.type).toBe('card')
expect(updated.topics).toEqual(['saude'])
expect(updated.pieceDateLabel).toBe('10/09/2026')
const row = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
overrideAccess: true,
})
expect(row.curatedFields).toEqual([
'title',
'description',
'topics',
'municipality',
'institution',
'pieceDate',
'transcript',
'type',
])
expect(row.searchText).toContain('titulo curado')
expect(row.searchText).toContain('camara dos deputados')
})
it('derives the city and the region from the related município', async () => {
const municipality = await payload.find({
collection: 'municipality',
where: { kind: { equals: 'municipio' } },
depth: 0,
limit: 1,
pagination: false,
sort: 'name',
overrideAccess: true,
})
const target = municipality.docs[0]!
const { piece } = await createPiece()
await payload.update({
collection: 'contentPiece',
id: piece.id,
data: { municipality: target.id },
overrideAccess: true,
})
const row = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
overrideAccess: true,
})
expect(row.cityLabel).toBe(target.name)
expect(row.region).toBe(target.region)
// The haystack is normalized (accents stripped); the city name is in it.
expect(row.searchText).toContain(normalizeForSearch(target.name))
})
it('creates a link piece without an official path as a peça-link', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const piece = await addContentPieceByLinkForActor({
url: 'https://www.youtube.com/watch?v=VIDEO1&utm_source=grupo',
})
createdPieceIds.add(piece.id)
expect(piece.origin).toBe('youtube')
expect(piece.processingStatus).toBe('processando')
const row = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
overrideAccess: true,
})
expect(row.sourceUrl).toBe('https://www.youtube.com/watch?v=VIDEO1')
expect(row.media).toBeNull()
// The job resolves no official path: the piece becomes a peça-link.
await runContentPieceJob(payload, piece.id, { catalog: catalogStub })
const resolved = await payload.findByID({
collection: 'contentPiece',
id: piece.id,
depth: 0,
overrideAccess: true,
})
expect(resolved.processingStatus).toBe('pronto')
expect(resolved.media).toBeNull()
})
it('refuses a duplicate link and an invalid one', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const url = 'https://www.instagram.com/reel/DUPLICADO/'
const first = await addContentPieceByLinkForActor({ url })
createdPieceIds.add(first.id)
await expect(addContentPieceByLinkForActor({ url: `${url}?igsh=abc` })).rejects.toThrow(
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
)
await expect(
addContentPieceByLinkForActor({ url: 'https://twitter.com/x/status/1' }),
).rejects.toThrow()
})
it('filters and searches the list', async () => {
const municipality = await payload.find({
collection: 'municipality',
where: { kind: { equals: 'municipio' } },
depth: 0,
limit: 1,
pagination: false,
sort: 'name',
overrideAccess: true,
})
const target = municipality.docs[0]!
// The suite shares one database and the rows of every test stay until the
// end: each assertion is scoped by a marker only this test creates.
const matchMarker = `escala-${Date.now()}`
const otherMarker = `card-${Date.now()}`
const match = await createPiece({
title: `Fim da escala 6x1 ${matchMarker}`,
topics: ['saude'],
institution: 'Câmara dos Deputados',
})
await payload.update({
collection: 'contentPiece',
id: match.piece.id,
data: { municipality: target.id },
overrideAccess: true,
})
const other = await createPiece({
title: `Card do giro ${otherMarker}`,
type: 'card',
status: 'publicado',
})
const idsOf = async (params: Record<string, string | string[]>) =>
(await loadContentPieceListPageData(payload, communicator, params)).rows.map((row) => row.id)
expect(await idsOf({ q: matchMarker })).toEqual([match.piece.id])
expect(await idsOf({ q: otherMarker })).toEqual([other.piece.id])
expect(await idsOf({ q: target.name.split(' ')[0]! })).toContain(match.piece.id)
expect(await idsOf({ q: 'camara dos deputados' })).toContain(match.piece.id)
expect(await idsOf({ type: ['card'] })).toContain(other.piece.id)
expect(await idsOf({ status: ['publicado'] })).toContain(other.piece.id)
expect(await idsOf({ processing: ['pronto'] })).toEqual(
expect.arrayContaining([match.piece.id, other.piece.id]),
)
// The loader answers for the communication roles only.
await expect(loadContentPieceListPageData(payload, leader, {})).rejects.toThrow()
})
})

View file

@ -12,6 +12,7 @@ import {
import {
CAMPAIGN_AGENDA_HOME,
CAMPAIGN_COMMUNICATION_ACERVO,
CAMPAIGN_COMMUNICATION_CONTEUDOS,
CAMPAIGN_COMMUNICATION_CORTES,
CAMPAIGN_COMMUNICATION_HOME,
CAMPAIGN_COMMUNICATION_REELS,
@ -98,20 +99,22 @@ describe('communicator sidebar', () => {
})
})
describe('communication vertical sub-items (C174, C194)', () => {
describe('communication vertical sub-items (C174, C194, C211)', () => {
const expectedSubHrefs = [
CAMPAIGN_COMMUNICATION_ACERVO,
CAMPAIGN_COMMUNICATION_CORTES,
CAMPAIGN_COMMUNICATION_REELS,
CAMPAIGN_COMMUNICATION_CONTEUDOS,
]
it('hangs the acervo, the cut library and the reels under Comunicação for the communicator', () => {
it('hangs the acervo, the cut library, the reels and the content Central under Comunicação for the communicator', () => {
const [item] = getCampaignNav('communicator')
expect(item?.subItems?.map((sub) => sub.href)).toEqual(expectedSubHrefs)
expect(item?.subItems?.map((sub) => sub.title)).toEqual([
'Acervo de falas',
'Biblioteca de cortes',
'Reels',
'Conteúdos',
])
})

View file

@ -85,6 +85,16 @@ describe('campaignPageChrome', () => {
expect(resolveCampaignPageChrome('/campanha/comunicacao/reels/42', 'communicator')).toBeNull()
})
it('resolves the content Central list and leaves the ficha title to the page (C211)', () => {
expect(resolveCampaignPageChrome('/campanha/comunicacao/conteudos', 'communicator')).toEqual({
title: 'Conteúdos',
subtitle: 'Peças que alimentam a Central pública.',
})
expect(
resolveCampaignPageChrome('/campanha/comunicacao/conteudos/42', 'communicator'),
).toBeNull()
})
it('separates staff contatos from the leader meus-contatos (C139)', () => {
expect(resolveCampaignPageChrome('/campanha/contatos', 'coordinator')).toEqual({
title: 'Contatos',

View file

@ -222,6 +222,14 @@ describe('campaign JSON mutation route convention', () => {
'src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/enviar/route.ts',
'raw-body recording upload (the video streams to disk, metadata in the query) — cookie-authenticated via campaign-token + explicit same-origin check; cannot ride the JSON wrapper',
],
[
'src/app/(campaign)/campanha/(app)/comunicacao/conteudos/enviar/route.ts',
'raw-body content piece upload (the file streams to disk, metadata in the query) — cookie-authenticated via campaign-token + explicit same-origin check; cannot ride the JSON wrapper',
],
[
'src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/arquivo/route.ts',
'GET serves the private piece file and POST attaches the original as a raw body — cookie-authenticated via campaign-token + explicit same-origin check; cannot ride the JSON wrapper',
],
[
'src/app/(campaign)/campanha/agenda/google-webhook/[secret]/route.ts',
'Google Calendar push delivery (empty body, not JSON) — public by design, authenticated by URL secret + X-Goog channel headers (never a campaign cookie)',

View file

@ -0,0 +1,319 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
canRetryContentPiece,
contentPieceFailureMessage,
contentPieceIsPublic,
contentPieceLinkTitle,
contentPieceOriginFromLink,
contentPieceSearchText,
contentPieceSlugBase,
contentPieceSlugCandidates,
contentPieceTitleFromFilename,
contentPieceTypeFromMime,
isContentPieceCuratedField,
isContentPieceOrigin,
isContentPieceProcessingStatus,
isContentPieceStatus,
isContentPieceStep,
isContentPieceTopic,
isContentPieceType,
needsContentPieceProcessing,
parseContentPieceLink,
sanitizeContentPieceFilename,
toContentPieceViewModel,
} from '@/lib/contentPiece'
import {
buildContentPieceListWhere,
parseContentPieceListParams,
resolveContentPieceListUrl,
} from '@/utilities/content/contentPieceListUrl'
describe('content piece vocabulary', () => {
it('guards every persisted enum', () => {
expect(isContentPieceType('video')).toBe(true)
expect(isContentPieceType('card')).toBe(true)
expect(isContentPieceType('reels')).toBe(false)
expect(isContentPieceStatus('publicado')).toBe(true)
expect(isContentPieceStatus('published')).toBe(false)
expect(isContentPieceProcessingStatus('falhou')).toBe(true)
expect(isContentPieceProcessingStatus('failed')).toBe(false)
expect(isContentPieceStep('catalogando')).toBe(true)
expect(isContentPieceStep('extracting')).toBe(false)
expect(isContentPieceCuratedField('transcript')).toBe(true)
expect(isContentPieceCuratedField('slug')).toBe(false)
expect(isContentPieceOrigin('arquivo')).toBe(true)
expect(isContentPieceOrigin('link')).toBe(false)
expect(isContentPieceTopic('saude')).toBe(true)
expect(isContentPieceTopic('nao-existe')).toBe(false)
})
it('knows which types need the pipeline', () => {
expect(needsContentPieceProcessing('video')).toBe(true)
expect(needsContentPieceProcessing('audio')).toBe(true)
expect(needsContentPieceProcessing('texto')).toBe(true)
expect(needsContentPieceProcessing('foto')).toBe(false)
expect(needsContentPieceProcessing('card')).toBe(false)
})
it('retries only a failed piece', () => {
expect(canRetryContentPiece('falhou')).toBe(true)
expect(canRetryContentPiece('pronto')).toBe(false)
expect(canRetryContentPiece('processando')).toBe(false)
})
it('is public only when published with something to show', () => {
expect(contentPieceIsPublic({ status: 'publicado', hasFile: true, sourceUrl: null })).toBe(true)
expect(
contentPieceIsPublic({ status: 'publicado', hasFile: false, sourceUrl: 'https://x/y' }),
).toBe(true)
expect(contentPieceIsPublic({ status: 'publicado', hasFile: false, sourceUrl: null })).toBe(
false,
)
expect(contentPieceIsPublic({ status: 'rascunho', hasFile: true, sourceUrl: null })).toBe(false)
})
it('maps the stored failure to honest copy by step', () => {
expect(contentPieceFailureMessage({ error: null })).toBeNull()
expect(contentPieceFailureMessage({ error: 'boom', step: 'transcrevendo' })).toBe(
'Não foi possível transcrever a peça.',
)
expect(contentPieceFailureMessage({ error: 'boom', step: 'catalogando' })).toBe(
'Não foi possível catalogar a peça.',
)
expect(
contentPieceFailureMessage({
error: 'O processamento foi interrompido antes de terminar.',
step: 'salvando',
}),
).toBe('O processamento foi interrompido antes de terminar.')
})
})
describe('contentPieceTypeFromMime', () => {
it('prefers the MIME family', () => {
expect(contentPieceTypeFromMime('video/mp4', 'x.mp4')).toBe('video')
expect(contentPieceTypeFromMime('audio/mpeg', 'x.mp3')).toBe('audio')
expect(contentPieceTypeFromMime('text/plain', 'x.txt')).toBe('texto')
expect(contentPieceTypeFromMime('image/png', 'x.png')).toBe('foto')
})
it('falls back to the extension when the OS sends no type', () => {
expect(contentPieceTypeFromMime('', 'fala.mkv')).toBe('video')
expect(contentPieceTypeFromMime(undefined, 'audio-grupo.wav')).toBe('audio')
expect(contentPieceTypeFromMime(null, 'mensagem.md')).toBe('texto')
expect(contentPieceTypeFromMime('', 'card-feira.jpg')).toBe('foto')
})
it('refuses what the catalogue does not read', () => {
expect(contentPieceTypeFromMime('application/pdf', 'plano.pdf')).toBeNull()
expect(contentPieceTypeFromMime('', 'arquivo.exe')).toBeNull()
expect(contentPieceTypeFromMime(null, undefined)).toBeNull()
})
})
describe('filename helpers', () => {
it('derives a human title from the filename', () => {
expect(contentPieceTitleFromFilename('fim-da_escala-6x1.mp4')).toBe('fim da escala 6x1')
expect(contentPieceTitleFromFilename('C:\\midia\\card feira.png')).toBe('card feira')
expect(contentPieceTitleFromFilename('.mp4')).toBe('Nova peça')
})
it('sanitizes the stored filename', () => {
expect(sanitizeContentPieceFilename('../../etc/passwd')).toBe('passwd')
expect(sanitizeContentPieceFilename('fala sus (1).mp4')).toBe('fala_sus_1_.mp4')
expect(sanitizeContentPieceFilename('')).toBe('peca')
})
it('builds the slug base and its candidates', () => {
expect(contentPieceSlugBase('Fim da escala 6×1 é saúde!')).toBe('fim-da-escala-6-1-e-saude')
expect(contentPieceSlugCandidates('Fim da escala 6×1 é saúde!').slice(0, 3)).toEqual([
'fim-da-escala-6-1-e-saude',
'fim-da-escala-6-1-e-saude-2',
'fim-da-escala-6-1-e-saude-3',
])
expect(contentPieceSlugCandidates('!!!', 2)).toEqual(['peca', 'peca-2'])
})
})
describe('content piece links', () => {
it('canonicalizes Instagram forms and drops tracking', () => {
expect(parseContentPieceLink('https://www.instagram.com/p/ABC123/?igsh=xyz')).toEqual({
origin: 'instagram',
shortcode: 'ABC123',
canonicalUrl: 'https://www.instagram.com/p/ABC123/',
})
expect(parseContentPieceLink('https://instagram.com/reels/ABC123')).toEqual({
origin: 'instagram',
shortcode: 'ABC123',
canonicalUrl: 'https://www.instagram.com/reel/ABC123/',
})
expect(parseContentPieceLink('https://m.instagram.com/tv/ABC123/')?.origin).toBe('instagram')
})
it('canonicalizes YouTube forms to the watch URL', () => {
expect(parseContentPieceLink('https://youtu.be/VIDEO1?t=10')).toEqual({
origin: 'youtube',
videoId: 'VIDEO1',
canonicalUrl: 'https://www.youtube.com/watch?v=VIDEO1',
})
expect(parseContentPieceLink('https://www.youtube.com/shorts/VIDEO2')).toEqual({
origin: 'youtube',
videoId: 'VIDEO2',
canonicalUrl: 'https://www.youtube.com/watch?v=VIDEO2',
})
const watch = parseContentPieceLink('https://www.youtube.com/watch?v=VIDEO3&utm_source=x')
expect(watch?.origin === 'youtube' ? watch.videoId : null).toBe('VIDEO3')
})
it('refuses other hosts and malformed input', () => {
expect(parseContentPieceLink('https://twitter.com/user/status/1')).toBeNull()
expect(parseContentPieceLink('https://www.instagram.com/')).toBeNull()
expect(parseContentPieceLink('https://www.instagram.com/p/')).toBeNull()
expect(parseContentPieceLink('nao e link')).toBeNull()
expect(parseContentPieceLink('')).toBeNull()
expect(contentPieceOriginFromLink('https://youtu.be/VIDEO1')).toBe('youtube')
expect(contentPieceOriginFromLink('https://example.com/x')).toBeNull()
})
it('gives a link a provisional title', () => {
const instagram = parseContentPieceLink('https://www.instagram.com/reel/ABC/')!
expect(contentPieceLinkTitle(instagram)).toBe('Instagram · ABC')
const youtube = parseContentPieceLink('https://youtu.be/VID')!
expect(contentPieceLinkTitle(youtube)).toBe('YouTube · VID')
})
})
describe('contentPieceSearchText', () => {
it('normalizes title, description, transcript, topics, city and institution', () => {
expect(
contentPieceSearchText({
title: 'Fim da escala 6×1 é Saúde',
description: 'Solla explica a jornada',
transcript: 'A redução da jornada protege a saúde',
institution: 'Câmara dos Deputados',
topics: ['saude', 'economia-trabalho'],
cityLabel: 'Feira de Santana',
}),
).toBe(
'fim da escala 6×1 e saude solla explica a jornada a reducao da jornada protege a saude camara dos deputados saude economia e trabalho feira de santana',
)
})
it('is empty when nothing is filled', () => {
expect(contentPieceSearchText({})).toBe('')
})
})
describe('toContentPieceViewModel', () => {
it('maps a ready piece and its links', () => {
const viewModel = toContentPieceViewModel({
id: 7,
title: 'Fim da escala',
type: 'video',
status: 'publicado',
processingStatus: 'pronto',
step: null,
origin: 'instagram',
topics: ['saude'],
cityLabel: 'Salvador — ZE 3',
region: 'Metropolitana',
durationSeconds: 134,
pieceDate: '2026-09-01',
publishedAt: '2026-09-02',
error: null,
media: { id: 11 },
})
expect(viewModel.title).toBe('Fim da escala')
expect(viewModel.typeLabel).toBe('Vídeo')
expect(viewModel.statusLabel).toBe('Publicado')
expect(viewModel.processingLabel).toBe('Pronto')
expect(viewModel.durationLabel).toBe('02:14')
expect(viewModel.pieceDateLabel).toBe('01/09/2026')
expect(viewModel.hasFile).toBe(true)
expect(viewModel.fileHref).toBe('/campanha/comunicacao/conteudos/7/arquivo')
expect(viewModel.retryHref).toBe('/campanha/comunicacao/conteudos/7/retry')
expect(viewModel.detailHref).toBe('/campanha/comunicacao/conteudos/7')
expect(viewModel.canRetry).toBe(false)
expect(viewModel.failureMessage).toBeNull()
expect(viewModel.topics).toEqual(['saude'])
})
it('normalizes unknown enum values fail-closed and maps a failure', () => {
const viewModel = toContentPieceViewModel({
id: 8,
title: null,
type: 'inexistente',
status: null,
processingStatus: null,
step: 'inexistente',
origin: null,
error: 'boom',
media: null,
})
expect(viewModel.title).toBe('Peça 8')
expect(viewModel.type).toBe('foto')
expect(viewModel.status).toBe('rascunho')
expect(viewModel.processingStatus).toBe('falhou')
expect(viewModel.step).toBeNull()
expect(viewModel.hasFile).toBe(false)
expect(viewModel.fileHref).toBeNull()
expect(viewModel.canRetry).toBe(true)
expect(viewModel.failureMessage).toBe('Não foi possível processar a peça.')
})
})
describe('content piece list URL', () => {
it('parses, canonicalizes and drops exhaustive facets', () => {
const state = parseContentPieceListParams({
q: ' escala ',
type: ['video', 'foto', 'texto', 'audio', 'card'],
status: ['rascunho'],
processing: ['falhou'],
page: '2',
})
expect(state).toEqual({
page: 2,
q: 'escala',
statuses: ['rascunho'],
processing: ['falhou'],
})
const resolved = resolveContentPieceListUrl({ type: ['video'], page: '1' })
expect(resolved.state).toEqual({ page: 1, types: ['video'] })
expect(resolved.href).toBe('/campanha/comunicacao/conteudos?type=video')
expect(resolved.redirectHref).toBe('/campanha/comunicacao/conteudos?type=video')
})
it('ignores unknown tokens and keeps an empty URL canonical', () => {
expect(parseContentPieceListParams({ type: ['nope'], page: '0' })).toEqual({ page: 1 })
expect(resolveContentPieceListUrl({}).href).toBe('/campanha/comunicacao/conteudos')
})
it('builds the where from the term and the facets', () => {
expect(buildContentPieceListWhere({ page: 1 })).toEqual({})
expect(
buildContentPieceListWhere({
page: 1,
q: 'Saúde',
types: ['video'],
statuses: ['publicado'],
processing: ['falhou'],
}),
).toEqual({
and: [
{ searchText: { contains: 'saude' } },
{ type: { in: ['video'] } },
{ status: { in: ['publicado'] } },
{ processingStatus: { in: ['falhou'] } },
],
})
expect(buildContentPieceListWhere({ page: 1, q: 'x' })).toEqual({
searchText: { contains: 'x' },
})
})
})

View file

@ -0,0 +1,196 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { describe, expect, it, vi } from 'vitest'
import type { SpeechTopic } from '@/lib/speechFacets'
import {
buildContentPieceFallbackMetadata,
catalogContentPiece,
resolveContentPieceInstitution,
resolveContentPieceMunicipalityId,
} from '@/utilities/content/contentPieceCataloging'
/**
* C211 — the automatic cataloguing is fail-closed: it fills only what it is
* confident about, never invents a city/institution, and a provider failure
* degrades to the deterministic fallback instead of breaking the job.
*/
const fakePayload = (
overrides: {
municipalitySlug?: string | null
municipalityName?: string | null
} = {},
): Payload =>
({
find: vi.fn(async () =>
overrides.municipalitySlug
? { docs: [{ id: 42, slug: overrides.municipalitySlug }] }
: { docs: [] },
),
findByID: vi.fn(async () =>
overrides.municipalityName ? { id: 42, name: overrides.municipalityName } : null,
),
}) as unknown as Payload
const classifierOf = (topics: SpeechTopic[]) => vi.fn(async () => ({ facets: { topics } }))
const suggesterOf = (title: string, description: string) =>
vi.fn(async () => ({ title, description, source: 'ai' as const }))
describe('resolveContentPieceMunicipalityId', () => {
it('resolves a single unambiguous city mention', async () => {
const payload = fakePayload({ municipalitySlug: 'feira-de-santana' })
await expect(
resolveContentPieceMunicipalityId({
payload,
transcript: 'Estivemos em Feira de Santana para falar da escala 6x1.',
}),
).resolves.toBe(42)
})
it('stays empty when no city or more than one city is mentioned', async () => {
const payload = fakePayload({ municipalitySlug: 'feira-de-santana' })
await expect(
resolveContentPieceMunicipalityId({ payload, transcript: 'Uma fala sobre saúde.' }),
).resolves.toBeNull()
await expect(
resolveContentPieceMunicipalityId({
payload,
transcript: 'Passamos por Feira de Santana e por Vitória da Conquista.',
}),
).resolves.toBeNull()
})
it('keeps Salvador ambiguous (one catalog entry per TSE zone)', async () => {
const payload = fakePayload({ municipalitySlug: 'salvador-ze-3' })
await expect(
resolveContentPieceMunicipalityId({ payload, transcript: 'Comício em Salvador.' }),
).resolves.toBeNull()
})
})
describe('resolveContentPieceInstitution', () => {
it('matches a catalog spelling as a whole word', () => {
expect(resolveContentPieceInstitution('A UFBA abriu o edital.')).toBe('UFBA')
expect(resolveContentPieceInstitution('Falamos sobre a Universidade Federal da Bahia.')).toBe(
'UFBA',
)
})
it('never matches a partial word and refuses ambiguity', () => {
expect(resolveContentPieceInstitution('A ufbanana nao existe.')).toBeUndefined()
expect(
resolveContentPieceInstitution('A UFBA e os Correios firmaram o acordo.'),
).toBeUndefined()
})
})
describe('buildContentPieceFallbackMetadata', () => {
it('never returns an empty title or description', () => {
const fallback = buildContentPieceFallbackMetadata({
type: 'video',
currentTitle: '',
cityLabel: null,
topics: [],
})
expect(fallback.title).toBe('Peça de vídeo')
expect(fallback.description).toContain('Vídeo da campanha de Jorge Solla 1313')
expect(fallback.source).toBe('fallback')
})
it('uses the city and the topic labels when present', () => {
const fallback = buildContentPieceFallbackMetadata({
type: 'audio',
currentTitle: 'Mensagem para lideranças',
cityLabel: 'Feira de Santana',
topics: ['saude', 'economia-trabalho'],
})
expect(fallback.title).toBe('Mensagem para lideranças')
expect(fallback.description).toBe(
'Áudio da campanha de Jorge Solla 1313 em Feira de Santana sobre Saúde, Economia e Trabalho.',
)
})
})
describe('catalogContentPiece', () => {
it('skips every external call when there is no text', async () => {
const classify = classifierOf([])
const suggest = suggesterOf('t', 'd')
const result = await catalogContentPiece({
payload: fakePayload(),
type: 'foto',
title: 'Card da feira',
transcript: null,
classify,
suggest,
})
expect(result).toEqual({ source: 'none' })
expect(classify).not.toHaveBeenCalled()
expect(suggest).not.toHaveBeenCalled()
})
it('fills city, topics, institution and the suggestion when confident', async () => {
const payload = fakePayload({
municipalitySlug: 'feira-de-santana',
municipalityName: 'Feira de Santana',
})
const result = await catalogContentPiece({
payload,
type: 'video',
title: 'Instagram · ABC',
transcript: 'A UFBA recebeu o anúncio em Feira de Santana sobre a jornada 6x1.',
classify: classifierOf(['saude', 'economia-trabalho']),
suggest: suggesterOf('Fim da escala 6x1', 'Solla explica a jornada.'),
})
expect(result).toEqual({
title: 'Fim da escala 6x1',
description: 'Solla explica a jornada.',
topics: ['saude', 'economia-trabalho'],
municipalityId: 42,
institution: 'UFBA',
source: 'ai',
})
})
it('keeps the fallback metadata when the suggester degrades', async () => {
const result = await catalogContentPiece({
payload: fakePayload(),
type: 'texto',
title: 'Mensagem do grupo',
transcript: 'Texto curto para o grupo.',
classify: classifierOf([]),
suggest: vi.fn(async () => ({
title: 'Mensagem do grupo',
description: 'Texto da campanha.',
source: 'fallback' as const,
})),
})
expect(result.source).toBe('fallback')
expect(result.title).toBe('Mensagem do grupo')
expect(result.description).toBe('Texto da campanha.')
expect(result.topics).toBeUndefined()
expect(result.municipalityId).toBeUndefined()
expect(result.institution).toBeUndefined()
})
it('survives a classifier that throws', async () => {
const result = await catalogContentPiece({
payload: fakePayload(),
type: 'video',
title: 'Peça',
transcript: 'Uma fala qualquer sem cidade.',
classify: vi.fn(async () => {
throw new Error('provider down')
}),
suggest: suggesterOf('Título', 'Descrição'),
})
expect(result.source).toBe('ai')
expect(result.topics).toBeUndefined()
})
})

View file

@ -125,6 +125,7 @@ describe('parseInstagramMediaResponse', () => {
caption: 'Legenda post1',
mediaType: 'IMAGE',
permalink: 'https://www.instagram.com/p/post1/',
mediaUrl: 'https://scontent.cdninstagram.com/post1.jpg',
thumbnailUrl: 'https://scontent.cdninstagram.com/post1.jpg',
timestamp: '2026-08-18T10:00:00+00:00',
})
@ -162,6 +163,7 @@ describe('eligibleInstagramPosts', () => {
caption: 'A',
mediaType: 'IMAGE',
permalink: 'https://instagram.com/p/a/',
mediaUrl: null,
timestamp: 't1',
},
{
@ -169,6 +171,7 @@ describe('eligibleInstagramPosts', () => {
caption: 'B',
mediaType: 'IMAGE',
permalink: 'https://instagram.com/p/b/',
mediaUrl: null,
timestamp: 't2',
},
{
@ -176,6 +179,7 @@ describe('eligibleInstagramPosts', () => {
caption: 'C',
mediaType: 'IMAGE',
permalink: 'https://instagram.com/p/c/',
mediaUrl: null,
timestamp: 't3',
},
{
@ -183,6 +187,7 @@ describe('eligibleInstagramPosts', () => {
caption: 'D',
mediaType: 'IMAGE',
permalink: 'https://instagram.com/p/d/',
mediaUrl: null,
timestamp: 't4',
},
]