mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
6d721ec709
commit
71a27aa4ec
60 changed files with 25671 additions and 14 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -59,6 +59,7 @@ next-env.d.ts
|
|||
/media
|
||||
/reelMedia
|
||||
/recordingMedia
|
||||
/contentMedia
|
||||
|
||||
# TSE open-data zip cache (pnpm db:seed:tse)
|
||||
/data/tse/
|
||||
|
|
|
|||
1
docs/changelog/2026-09-22-c211.md
Normal file
1
docs/changelog/2026-09-22-c211.md
Normal 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`).
|
||||
236
docs/plans/central-conteudos-ingestao-impl.md
Normal file
236
docs/plans/central-conteudos-ingestao-impl.md
Normal 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.
|
||||
54
docs/plans/escala-dry-pos-c211.md
Normal file
54
docs/plans/escala-dry-pos-c211.md
Normal 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.**
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
131
src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx
Normal file
131
src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
|
|
@ -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 }
|
||||
311
src/app/(campaign)/campanha/actions/contentPieces.ts
Normal file
311
src/app/(campaign)/campanha/actions/contentPieces.ts
Normal 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
|
||||
}
|
||||
}
|
||||
45
src/collections/ContentMedia.ts
Normal file
45
src/collections/ContentMedia.ts
Normal 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,
|
||||
}
|
||||
433
src/collections/ContentPiece.ts
Normal file
433
src/collections/ContentPiece.ts
Normal 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 }),
|
||||
],
|
||||
}
|
||||
179
src/components/campaign/content/AddContentPieceLinkDialog.tsx
Normal file
179
src/components/campaign/content/AddContentPieceLinkDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
src/components/campaign/content/ContentPieceAttachFileButton.tsx
Normal file
106
src/components/campaign/content/ContentPieceAttachFileButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
src/components/campaign/content/ContentPieceCardList.tsx
Normal file
64
src/components/campaign/content/ContentPieceCardList.tsx
Normal 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>
|
||||
)
|
||||
214
src/components/campaign/content/ContentPieceFilters.tsx
Normal file
214
src/components/campaign/content/ContentPieceFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
212
src/components/campaign/content/ContentPieceForm.tsx
Normal file
212
src/components/campaign/content/ContentPieceForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
88
src/components/campaign/content/ContentPieceRetryButton.tsx
Normal file
88
src/components/campaign/content/ContentPieceRetryButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
src/components/campaign/content/ContentPieceStatusBadge.tsx
Normal file
56
src/components/campaign/content/ContentPieceStatusBadge.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
123
src/components/campaign/content/ContentPieceTable.tsx
Normal file
123
src/components/campaign/content/ContentPieceTable.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
370
src/components/campaign/content/ContentPieceUploadDialog.tsx
Normal file
370
src/components/campaign/content/ContentPieceUploadDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
525
src/lib/contentPiece.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
145
src/lib/schemas/contentPiece.ts
Normal file
145
src/lib/schemas/contentPiece.ts
Normal 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
|
||||
17992
src/migrations/20260923_032714_add_content_piece.json
Normal file
17992
src/migrations/20260923_032714_add_content_piece.json
Normal file
File diff suppressed because it is too large
Load diff
129
src/migrations/20260923_032714_add_content_piece.ts
Normal file
129
src/migrations/20260923_032714_add_content_piece.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
45
src/utilities/access/contentPieces.ts
Normal file
45
src/utilities/access/contentPieces.ts
Normal 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
|
||||
}
|
||||
|
|
@ -184,3 +184,9 @@ export {
|
|||
canReadRecording,
|
||||
canUploadRecording,
|
||||
} from '@/utilities/access/recordings'
|
||||
|
||||
export {
|
||||
canCreateContentPiece,
|
||||
canReadContentPiece,
|
||||
canUpdateContentPiece,
|
||||
} from '@/utilities/access/contentPieces'
|
||||
|
|
|
|||
266
src/utilities/content/contentPieceCataloging.ts
Normal file
266
src/utilities/content/contentPieceCataloging.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
337
src/utilities/content/contentPieceJob.ts
Normal file
337
src/utilities/content/contentPieceJob.ts
Normal 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
|
||||
}
|
||||
193
src/utilities/content/contentPieceLink.ts
Normal file
193
src/utilities/content/contentPieceLink.ts
Normal 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 }
|
||||
}
|
||||
155
src/utilities/content/contentPieceListUrl.ts
Normal file
155
src/utilities/content/contentPieceListUrl.ts
Normal 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 }
|
||||
}
|
||||
176
src/utilities/content/contentPiecePageData.ts
Normal file
176
src/utilities/content/contentPiecePageData.ts
Normal 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 ?? [],
|
||||
},
|
||||
}
|
||||
}
|
||||
21
src/utilities/content/contentPieceScheduler.ts
Normal file
21
src/utilities/content/contentPieceScheduler.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
294
src/utilities/content/contentPieceUpload.ts
Normal file
294
src/utilities/content/contentPieceUpload.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
787
tests/int/contentPiece.int.spec.ts
Normal file
787
tests/int/contentPiece.int.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
319
tests/unit/contentPiece.unit.spec.ts
Normal file
319
tests/unit/contentPiece.unit.spec.ts
Normal 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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
196
tests/unit/contentPieceCataloging.unit.spec.ts
Normal file
196
tests/unit/contentPieceCataloging.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue