feat(C199): gravações enviadas no acervo de comunicação (Closes #1166)

This commit is contained in:
Francisco Solla 2026-09-18 22:59:03 -03:00
parent a8a5ed324f
commit 5e4b1dd481
72 changed files with 22754 additions and 309 deletions

1
.gitignore vendored
View file

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

View file

@ -0,0 +1 @@
**C199 — Gravações enviadas no acervo de comunicação (2026-09-18):** o acervo só tinha o que a Câmara publica; plenárias, debates e material próprio ficavam em pastas e celulares, sem transcrição nem busca. Entregou-se a segunda fonte do acervo em `/campanha/comunicacao/acervo` (`?source=enviadas`, alternador visível): (1) **collections** `recording` (título, data, `status` `uploading→processing→ready→failed`, `step`/`error`/`durationSeconds`/`searchText`), `recordingSegment` (transcrição com timestamps, hook de `searchText`, índice trigram) e `recordingMedia` (upload privado separado de `media`, registrado no S3) com access da vertical (`communicator`/`coordinator`/`candidate`; `advisor`/`leader` fail-closed) e cascade dos segmentos no delete; (2) **upload raw-body** `POST .../gravacoes/enviar` — o corpo é o vídeo, metadados na query, streaming para temp com teto de 4 GiB e `upload.onprogress` no diálogo (server action de 1 MB e FormData bufferizado ficaram fora); (3) **job de transcrição** no padrão do corte (`after()` + reaper preguiçoso de 60 min, que também remove `uploading` órfão): ffmpeg extrai/divide o áudio em chunks de 20 min e o dono `deepInfraTranscribe` ganhou `deepInfraTranscribeSegments` (`verbose_json` + segmento), mesclando offsets; falha preserva o arquivo e habilita "Reprocessar transcrição"; (4) **busca própria** (`searchText` trigram, unidade = gravação, 1 segmento por resultado para excerpt com timestamps; sem facetas da Câmara) e detalhe em `/acervo/gravacoes/[id]` com player privado servido por rota autenticada (`/arquivo`, Range/206/416, `?download=1`), transcrição clicável, download e confirmação de apagar; (5) **mídia privada generalizada**: `utilities/privateMedia/privateMediaResponse.ts` + `lib/privateMedia.ts` passam a ser o dono único do serving privado (a rota C193 dos reels consome daqui; `runFfmpeg` foi para `utilities/media/ffmpeg`), `mimeType` allowlist ganhou MOV/MKV/WebM para o player; (6) **migration** `20260919_003440_add_recording` com os índices trigram; (7) **testes**: 17 unit novos, 13 int (access, upload/limite, job feliz/falha/retry/reaper, rota de arquivo, busca) e os e2e da vertical estendidos (fonte, busca, detalhe, papéis, guardas do upload); design portado do artefato aprovado e certificado pelo `designer` (tier primário).

View file

@ -0,0 +1,227 @@
# Impl: Gravações enviadas no acervo de comunicação
Status: aprovado
Atualizado em: 2026-09-18
Issue: #1166
Intenção: docs/plans/acervo-gravacoes-enviadas.md
Appetite restante: herdado (~3–4 dias eng; um outcome verificável — a assessoria sobe uma gravação e ela fica pesquisável com player e transcrição)
## Leitura da intenção
- **Outcome:** a assessoria de comunicação (`communicator`, e `coordinator`/`candidate`) envia um arquivo de vídeo local pelo acervo, acompanha o estado até "Pronto" e passa a achar a fala de **qualquer pessoa** da gravação por busca textual com trecho + timestamps, abrindo o detalhe com player, transcrição clicável e "Baixar"; a gravação é privada (só quem tem o acervo), nunca URL pública, e falha preserva o arquivo com "Reprocessar transcrição".
- **O que NÃO negociar:**
- `advisor`/`leader` fail-closed em página, collection e rota de mídia (`canReadCommunicationCatalog`, `src/lib/campaignRoles.ts:27-28`).
- Gravação servida só sob `/campanha` (cookie `campaign-token` path `/campanha`, `src/utilities/campaignAuth.ts:12-13`); `Media.read` público intocado e sem URL pública nova.
- Nenhum item de nav novo (`src/components/campaign/shell/nav.ts:61-64`; pin em `tests/unit/campaignNav.unit.spec.ts:100-127`) e nenhuma alteração no contrato de URL da lista da Câmara (`q|mode|year|topic|scope|phase|municipality|duration|page`, `src/utilities/speech/speechListUrl.ts:80-90`).
- Sem segundo cadastro de pessoa, sem `Consent` novo (mídia interna de staff, sem PII), sem diarização (C200), sem corte/clipe, sem publicação, sem score exposto, transcrição somente leitura.
- **O que reavaliar:**
- "Reusar `Speech`/`SpeechSegment`" é o caminho que a intenção sugere como precedente (C174) — a exploração mostra que o precedente C174 é de **cortes** (OR de ids na mesma busca) e não serve para outra unidade; abaixo a decisão muda para collections próprias.
- O artefato de UI não cobre apagar, detalhe em estados não-prontos, reprocessar em andamento, erro de limite e paginação — ver §UI.
- Quem apaga e a data da gravação ficam como decisões assumidas (as recomendações A da intenção) a confirmar no gate.
## Abordagem recomendada
```mermaid
flowchart TD
B[Browser comunicador] -->|"POST /acervo/gravacoes/enviar<br/>raw body + title/date na query"| U[rota de upload<br/>isSameOrigin + getCampaignUser + canUploadRecording]
U -->|"cria row status=uploading"| R[(recording)]
U -->|"stream p/ temp + limite 4 GiB"| T[filePath]
T -->|"tx: recordingMedia + row<br/>status=processing/step=extracting"| R
U -->|"after()"| S[recordingScheduler]
S --> J[recordingJob]
J -->|"privateMediaStorage -> temp"| M[(recordingMedia S3/disk)]
J -->|"ffmpeg: mp3 mono 16k / chunks 20min"| C[chunks]
C -->|"deepInfraTranscribeSegments<br/>verbose_json + segment"| DI[Deep Infra Whisper]
DI -->|"merge offsets"| J
J -->|"tx: recordingSegment[] + searchText<br/>status=ready"| R
J -->|"erro: failed + error (arquivo preservado)"| R
P[Página /acervo?source=enviadas] -->|find recording searchText LIKE| R
P -->|"1 segmento por resultado (limit 1)"| SG[(recordingSegment)]
D[Detalhe /acervo/gravacoes/:id] -->|"GET /gravacoes/:id/arquivo (Range)"| PR[privateMediaResponse]
PR --> M
F[Falhou + Reprocessar] -->|"POST /gravacoes/:id/retry"| J
X[Apagar confirmado] -->|"DELETE /gravacoes/:id/apagar"| R
```
**Opções consideradas:** A | B | C em cada decisão abaixo
**Recomendação:** collections próprias `recording`/`recordingSegment`/`recordingMedia`; serving de mídia privada generalizado para um dono neutro; upload por rota POST com streaming cru; ASR com timestamps por extensão do dono `deepInfraTranscribe` + ffmpeg em chunks; job no padrão `speechCutJob`; alternador por query param `source=enviadas` com contrato da Câmara intocado; busca própria sobre `searchText` trigram + 1 segmento por resultado.
### Decisões de engenharia
**D1 — Modelo de dados.**
`Opções: A) recording + recordingSegment + recordingMedia novas | B) Speech/SpeechSegment com campos novos e discriminador | C) recording nova + SpeechSegment polimórfico (relationTo: ['speech','recording'])`
`Recomendação: A — porque a unidade, o access, os campos e a busca da gravação não são os da Câmara; espelhar a tríade (Speech/SpeechSegment + SpeechCut como precedente de status/job) reaproveita o conhecimento sem acoplar o contrato prod pinado.`
- A: `Recording` com `title`, `recordedAt`, `status/step/error`, `durationSeconds`, `searchText`, `media` (upload), `uploadedBy`; `RecordingSegment` com `recording`, `order`, `startSeconds`, `endSeconds`, `text`, `searchText` (hook derivando, como `SpeechSegment.ts:13-18`); access próprio em `utilities/access/recordings.ts`. Custo: espelha a forma dos segmentos; mitigado reusando os puros (`normalizeForSearch`, `buildHighlightedExcerpt`, `pickMatchingSegment` extraído) e mantendo o loader da Câmara intacto.
- Alternativas rejeitadas:
- **B** porque `Speech` tem invariantes que a gravação não tem (`sourceKey` único, `speechAt`, `classifiedBy`, `preserveManualFacets` em `Speech.ts:43-57`) e o `upsertSpeechBundle` é declarado o ÚNICO escritor de segmentos/searchText (`speechImport.ts:148-172`); um segundo escritor com outra invariante quebra isso. Pior: toda leitura da Câmara teria de discriminar a fonte — `loadSpeechFilterOptions` (`speechPageData.ts:85-121`), o OR de cortes C174 (`speechListFilters.ts:105-117`), o poster (`speechPosterJob.ts:94`) e a resolução de VOD (`actions/speech.ts:104-116`) — regressão no contrato pinado sem ganho.
- **C** porque mexe na tabela `speech_segment` (prod, C153) e no seu índice/access pinados, transforma o cascade `deleteSpeechSegments` (`Speech.ts:60-69`) em polimorfismo e não gera nenhuma query compartilhada: as duas buscas nunca se cruzam.
**D2 — Mídia privada (serving e armazenamento).**
`Opções: A) recordingMedia nova + generalizar o módulo de serving para um dono neutro | B) reusar a collection reelMedia | C) renomear/absorver reelMedia numa collection genérica com migração`
`Recomendação: A — o dono do serving é o módulo, não a rota; reelMediaResponse.ts já é genérico no que importa (recebe {filename, filesize, mimeType} + staticDir e monta Response com Range) e só está mal endereçado/nomeado para reels.`
- A: mover a mecânica para `src/utilities/privateMedia/privateMediaResponse.ts` (**um** módulo dono: cliente S3 cacheado via `resolveS3StorageEnv` fail-closed + fallback disco com guard de path + `buildPrivateMediaResponse` 404/206/416 com Range via `getRangeRequestInfo`, extraído de `reelMediaResponse.ts:32-173` + `downloadPrivateMediaToFile` para o job) e `src/lib/privateMedia.ts` (puro; renomeia `lib/reelMedia.ts:8-66`). A rota C193 (`reels/[id]/media/[kind]/route.ts:60-65`) passa a consumir o dono neutro (mudança mecânica; os testes C193 são a rede). O job de transcrição reusa o mesmo módulo para baixar os bytes (nada de segundo cliente S3).
- Alternativas rejeitadas:
- **B** porque `reelMedia` é o artefato do reel (labels, `admin.description`, relação 1:1 com `Reel`); guardar vídeo de gravação ali faz o modelo mentir e cria acoplamento de domínio.
- **C** porque renomear tabela em prod (C193 já mergeado/deployável) quebra rota, tipos e testes por zero produto; o compartilhado é só o predicado de acesso.
**D3 — Upload grande.**
`Opções: A) rota POST sob /campanha com streaming cru para temp + payload.create(filePath) | B) criar row via JSON e subir o binário num PUT | C) clientUploads do plugin S3 | D) server action com bodySizeLimit maior`
`Recomendação: A — rota handler não tem o default de 1 MB das server actions nem o buffer obrigatório do FormData; o corpo cru dispensa parser/dep nova (verificado: não há busboy/formidable/multer em package.json) e o XHR dá upload.onprogress real; o plugin S3 consome filePath/tempFilePath com fs.createReadStream + @aws-sdk/lib-storage, então um arquivo de horas nunca vira Buffer JS.`
- A: `POST /campanha/comunicacao/acervo/gravacoes/enviar?title&recordedAt&filename`; ordem fail-closed: `isSameOriginRequest` (rota crua não usa o wrapper; entra na allowlist dos conventions — precedente `ai-transcribe`, `tests/unit/codebaseConventions.unit.spec.ts:217-220`) → `getCampaignUser` + `canUploadRecording` → zod (`src/lib/schemas/recording.ts`) → `Content-Length` ≤ limite → cria row `uploading` → stream com contador de bytes (`RECORDING_MAX_BYTES = 4 GiB`, decisão de implementação do valor que a intenção delegou) → temp em `mkdtemp` → transação `recordingMedia` (`payload.create({ filePath, user, overrideAccess: false })`) + row `processing/extracting` → `startRecordingJobInBackground` → limpa temp no `finally`. Falha de stream/DB: apaga a row `uploading` (não há arquivo preservado; "Enviando" não vira "Falhou" fantasma) e devolve mensagem mapeada (`campaignJsonMutationErrorResponse`). MIME: aceita vazio ou `video/*` (`.mkv` costuma vir sem type); o content-type servido é sanitizado pelo módulo puro.
- Alternativas rejeitadas:
- **B** porque duas idas ao servidor e um PUT fora das varreduras POST/DELETE dos conventions (`codebaseConventions.unit.spec.ts:227-261`) não compram nada: a validação de metadados é a mesma via zod, e a interrupção precisa do mesmo reaper.
- **C** já rejeitada no C193: `signedDownloads` aponta para `host.docker.internal:3900`, inalcançável do browser (`docs/plans/reels-reel-privado-impl.md:49`).
- **D** porque `bodySizeLimit` só levanta o teto do buffer; a action continuaria bufferizando o arquivo inteiro (precedentes que bufferizam: `actions/demand.ts:236-240`, `actions/profile.ts:40-50`).
**D4 — ASR com timestamps para horas.**
`Opções: A) reusar deepInfraTranscribe (sem timestamps) | B) estender o dono com verbose_json + segment e transcrever em chunks de áudio extraído por ffmpeg | C) uma requisição para o arquivo inteiro | D) diarizar por pessoa`
`Recomendação: B — A não entrega o requisito (timestamps por segmento); C não sobrevive a horas de arquivo (limite de tamanho/processamento do provedor); D é C200.`
- B: `src/utilities/ai/deepInfraTranscribe.ts` (dono do provider) ganha `deepInfraTranscribeSegments(file, { timeoutMs })` com `response_format=verbose_json` + `timestamp_granularities[]=segment` e timeout de 600 s — o padrão já validado pelo CLI (`scripts/lib/camaraFetch.mjs:226-250`) — e a normalização da resposta (`segments` OpenAI / `chunks` whisper) portada de `scripts/lib/camaraSpeeches.mjs:311-339`. A rota de voz (`ai-transcribe/route.ts`) fica intocada.
- Job: baixa o objeto privado para temp pelo módulo do D2; extrai e divide numa passada de ffmpeg — `-vn -ac 1 -ar 16000 -c:a libmp3lame -b:a 32k -f segment -segment_time 1200 -reset_timestamps 1` (chunks de 20 min ≈ 4,8 MB, folga sobre o limite ~25 MB do endpoint); posta cada chunk (Buffer sequencial) e mescla com `offset = índice * 1200`, clampando o fim pela duração que a resposta traz; `durationSeconds` = soma das durações dos chunks (o áudio é a evidência; sem `ffprobe` no runtime — só `ffmpeg` na imagem, `Dockerfile:69-72`). Puros em `src/lib/recordingTranscription.ts` (offset/merge/searchText) unit-testados. Falha do provider marca `failed` com o arquivo preservado.
- O `runFfmpeg` sai de `speech/speechMediaPipeline.ts:73-98` para `src/utilities/media/ffmpeg.ts` com um `timeoutMs` opcional (a duração é desconhecida antes da extração e o clamp atual de 300 s em `speechMediaPipeline.ts:79-82` é apertado para um vídeo de horas); os jobs da Câmara mantêm os defaults. `downloadSource` (específico da Câmara) fica onde está.
**D5 — Job e status.**
`Opções: A) padrão speechCutJob (after() + row com status/step/error + reaper preguiçoso + heartbeat) | B) fila/worker | C) processar dentro da requisição de upload`
`Recomendação: A — é o mecanismo já estabelecido e testado na casa; B é o rabbit hole "fila/infra" que a intenção cortou; C estoura o request e apaga o desenho de estados.`
- A: `status: uploading | processing | ready | failed` e `step: extracting | transcribing | saving` vivem na `recording` (forma de `SpeechCut.ts:114-135`); a transição é `route → processing/extracting`, `job: extracting → transcribing → saving → ready`, `catch → failed + error`, `retry: failed → processing`, `reaper: processing estagnado → failed`. Agendador `after()` (`speechCutScheduler.ts:15-19`); o job atualiza o step a cada chunk (o `updatedAt` vira heartbeat) e o reaper usa janela de 60 min (`RECORDING_STALE_MS`; maior que os 15 min do corte, `speechCutJob.ts:37`, porque uma chamada de chunk pode durar minutos), disparado pela rota de status do poll. A UI mostra só os quatro estados do artefato; `step`/`error` são internos/ops.
**D6 — Alternador e URL.**
`Opções: A) mesmo /campanha/comunicacao/acervo com ?source=enviadas, resolvido por um módulo de URL próprio sobre o resolveListUrl compartilhado | B) inserir source no state/serializer de speechListUrl | C) rota de lista separada /acervo/gravacoes`
`Recomendação: A — mantém a superfície que a intenção fixou ("no mesmo /campanha/comunicacao/acervo via alternador", acervo-gravacoes-enviadas.md:61) e deixa o contrato congelado da Câmara intocado: como source não entra em speechListParamNames (speechListUrl.ts:80-90), um source perdido na branch da Câmara continua "não suportado" e canonicaliza para /acervo pelo resolveListUrl (campaignListUrl.ts:134-141) — exatamente o saneamento desejado, sem risco de regressão.`
- Literais: param `source`; valores `camara` (default; nunca serializado) e `enviadas` (serializado) — mesmos `data-source-tab` do artefato; detalhe em `/campanha/comunicacao/acervo/gravacoes/[id]`; arquivo em `.../gravacoes/[id]/arquivo` (`?download=1`), retry `.../gravacoes/[id]/retry`, apagar `.../gravacoes/[id]/apagar`, status `.../gravacoes/status`, envio `.../gravacoes/enviar`. `GET /acervo/gravacoes` (sem id) redireciona para `/acervo?source=enviadas` (alias canônico; evita a ambiguidade do segmento estático sem page sombreando `[id]`).
- Chrome: entrada nova no catálogo (`gravacoes: { title: 'Gravações enviadas' }`) e regra específica para `/campanha/comunicacao/acervo/gravacoes` **antes** da genérica `^/campanha/comunicacao/acervo/[^/]+$` (`campaignPageChrome.ts:297`), com o comentário da lição C194 (precedente `cortes`, `campaignPageChrome.ts:291-295`); o detalhe (`/gravacoes/<id>`) não casa a genérica e a página fixa `SetCampaignPageChrome` ("Gravação enviada" / "Acervo", Cena 3). O subtítulo do catálogo `acervo` passa à frase do artefato ("Busque nas falas da Câmara ou nas gravações da equipe."), atualizando o pin de `tests/unit/campaignPageChrome.unit.spec.ts:65-68`.
- Alternativas rejeitadas: **B** porque alarga um contrato pinado e enfia `source` no `SpeechListState` que o omnibox consome (`speechOmnibox.ts:17-27`) sem ganho; **C** porque contraria a intenção (o alternador vira rota) e parte a URL canônica do acervo.
**D7 — Busca da fonte nova.**
`Opções: A) searchText próprio (trigram) + 1 segmento por resultado para excerpt/timestamp | B) reusar buildSpeechListWhere | C) merge das duas fontes numa lista/consulta`
`Recomendação: A — a unidade do resultado é a gravação e não há facetas/keywords/tema/cortes; B obrigaria a forkar uma função tipada a SpeechListState com branches sem sentido para gravação (speechListFilters.ts:34-86); C é o rabbit hole "merge unificado" que a intenção cortou.`
- A: `where = { searchText: { like: normalizeForSearch(q) } }` (`normalizeForSearch` é o dono, `lib/speechSearch.ts:9-15`); `payload.find` com `select` mínimo + `overrideAccess:false,user`, `sort '-createdAt'`, page size 25; com `q`, **uma** query de segmento por resultado (`{ recording: equals, searchText: like }`, `sort 'order'`, `limit 1`, em paralelo, teto = page size) para excerpt + `startSeconds` — sem carregar todos os segmentos de gravações de horas (o `loadSegmentsForSpeeches` da Câmara carrega a página inteira porque falas duram minutos; aqui não serve). Se o LIKE casar só na concatenação entre segmentos, cai no fallback por termos — `pickMatchingSegment` sai de `speechViewModels.ts:183-196` para `lib/speechHighlight.ts` (puro, já dono de `matchesSearchTerms`) e é reusado. `watchHref = /acervo/gravacoes/<id>?t=<s>&q=<q>` (contrato de `buildWatchHref`, `speechViewModels.ts:233-251`); excerpt renderizado com o `SpeechHighlightParts` existente; nenhum score exposto.
## Componentes / mudanças
- **`Recording`** (`src/collections/Recording.ts`, novo): slug `recording`; labels `Gravação`/`Gravações`; `admin.group: 'Comunicação'`; `useAsTitle: 'title'`; access `create/update = canUploadRecording`, `read = canReadRecording`, `delete = canDeleteRecording`; hook `stampCampaignCreatedBy`; campos `title` (maxLength), `recordedAt` (date, index, opcional — fallback de exibição no upload), `status` (select, index, default `uploading`), `step` (select, readOnly), `media` (upload → `recordingMedia`, readOnly), `durationSeconds`, `searchText` (textarea readOnly), `error` (textarea readOnly), `systemStampedActorField`.
- **`RecordingSegment`** (`src/collections/RecordingSegment.ts`, novo): slug `recordingSegment`; `admin.hidden: () => true`; access create/update/delete admin-only, read `canReadRecording`; campos `recording` (rel index), `order`, `startSeconds`, `endSeconds`, `text`, `searchText` com hook de derivação (espelho de `SpeechSegment.ts:13-18`).
- **`RecordingMedia`** (`src/collections/RecordingMedia.ts`, novo): slug `recordingMedia`; upload privado, `alt` obrigatório, access pelo D1/D2; nunca consumido por `/api/media/file`; `admin.group: 'Comunicação'` com descrição de privacidade (forma de `ReelMedia.ts:15-43`).
- **`src/utilities/access/recordings.ts`** (novo): `canReadRecording`, `canUploadRecording`, `canDeleteRecording` (predicados separados, nunca alias — forma de `access/speeches.ts:19-44`); reexport no barrel `campaignAccess.ts`.
- **`src/lib/recording.ts`** (novo): statuses/steps/labels pt-BR, `RECORDING_MAX_BYTES`, MIME aceito, títulos/mensagens de erro compartilhadas com o cliente (client-safe).
- **`src/lib/schemas/recording.ts`** (novo): zod do metadata do upload, retry, delete e status (padrão `lib/schemas/speechCut.ts`).
- **`src/lib/recordingTranscription.ts`** (novo, puro): `mergeChunkTranscriptions` (offset/clamp/dedupe), `recordingSearchText` (concatenação normalizada).
- **`src/lib/privateMedia.ts`** (novo; move `src/lib/reelMedia.ts:8-66`, nomes genéricos).
- **`src/utilities/privateMedia/privateMediaResponse.ts`** (novo; extraído de `src/utilities/reels/reelMediaResponse.ts:32-173`; a rota C193 passa a importar daqui).
- **`src/utilities/media/ffmpeg.ts`** (novo): `runFfmpeg`/`ffmpegBinary`/`messageOf` movidos de `speech/speechMediaPipeline.ts:17-29,73-98`, com `timeoutMs` opcional; `speechMediaPipeline.ts` mantém a API para os jobs da Câmara.
- **`src/utilities/ai/deepInfraTranscribe.ts`** (editar): adicionar `deepInfraTranscribeSegments` (verbose_json + segment, timeout 600 s) e normalização no dono.
- **`src/utilities/recordings/`** (novo): `recordingListUrl.ts` (state `{source,q,page}` sobre `campaignListUrl.resolveListUrl`), `recordingListFilters.ts` (where do D7), `recordingViewModels.ts` (labels, excerpt, hrefs), `recordingPageData.ts` (lista+detalhe), `recordingUpload.ts` (D3), `recordingJob.ts` + `recordingScheduler.ts` + `recordingTranscriptionJob` (D4/D5, puros extraídos).
- **Rotas** (`src/app/(campaign)/campanha/(app)/comunicacao/acervo/`): `gravacoes/enviar/route.ts` (POST cru, allowlist), `gravacoes/[id]/arquivo/route.ts` (GET Range), `gravacoes/[id]/retry/route.ts` (wrapper), `gravacoes/[id]/apagar/route.ts` (DELETE + `isSameOriginRequest`), `gravacoes/status/route.ts` (wrapper), `gravacoes/page.tsx` (redirect alias), `gravacoes/[id]/page.tsx` (detalhe); `page.tsx` do acervo ganha o branch do `source`.
- **Actions** (`src/app/(campaign)/campanha/actions/recording.ts`, novo): `retryRecordingForActor`, `deleteRecordingForActor` (transação segmentos+row; delete do `recordingMedia` pós-commit via `onPayloadTransactionCommit`, `payloadTransaction.ts:37-48`), `getRecordingStatusesForActor` (reaper preguiçoso entre os ids).
- **`payload.config.ts`** (editar): registrar as três collections e `recordingMedia: true` no `s3Storage` (`payload.config.ts:166-172`) — sem isso, prod cai no disco efêmero.
- **Manifest e2e** (`scripts/lib/e2e-affected-manifest.mjs`): prefixos novos (`src/utilities/recordings`, `src/utilities/privateMedia`, `src/utilities/media`, `src/components/campaign/recording`, `src/lib/recording`) na entrada da vertical (linhas 305-317); na entrada de risco `src/utilities/ai` (linhas 49-55, 261-264) somar `campaignSpeechAcervo` a `campaignAiTranscribe`.
- **Conventions** (`tests/unit/codebaseConventions.unit.spec.ts:202-225`): allowlist do POST cru com a razão (streaming de vídeo, cookie + same-origin explícito).
- **Migration:** ver §Migration abaixo.
- **Access / Consent:** `utilities/access/recordings.ts` (sem `Consent` — mídia interna de staff, sem PII; nenhum opt-in nasce aqui).
- **UI:** Impeccable **C** — port do artefato aprovado + os complementos do designer listados em §UI; shells reusados (`CampaignListFooter`, `CampaignListEmptyState`, `CampaignListResults/PendingBoundary`, `CampaignSearchInput`, `AlertDialog`, `Badge`, `Spinner`). Shape→craft→critique→polish no ciclo de UI.
### Dados → forma (se aplicável)
- **N/A por decisão da intenção** (`acervo-gravacoes-enviadas.md:54-56`): resultados de busca não são métrica nem agregado; nenhum score de relevância é exposto como número. Nada a apresentar; nenhuma forma nova.
## UI e veredito do designer
**Coberto pelo artefato (`docs/plans/acervo-gravacoes-enviadas-ui-design.html`) — port mecânico:** alternador de fonte (Cena 1/Cena 6); lista com card de gravação, estado, data·duração, excerpt e "Abrir" (Cena 1); os quatro estados em card — Enviando (inclui barra de progresso), Processando, Falhou com "Reprocessar transcrição", Pronto (Cena 2); detalhe Pronto com player, transcrição clicável, "Baixar" e chrome "Gravação enviada / Acervo" (Cena 3); diálogo de envio com arquivo/título/data (Cena 4); vazio (Cena 5); mobile lista+detalhe (Cena 6). Semântica que o implementador decide sem mudar estrutura: o `role="tablist"` do artefato vira dois `<Link>`s com `aria-current` e o mesmo estilo de pill (navegação, não tab widget); `<input type="date">` nativo no lugar do campo mascarado.
**Falta superfície visual nova — o `designer` precisa estender ANTES do markup:**
1. **Confirmação de apagar** (exigida pela intenção `:66`, ausente do artefato): `AlertDialog` com título/descrição/CTA destrutivo; definir se o gatilho vive só no detalhe ou também nos cards da lista; a copy deve dizer que remove transcrição e arquivo, irreversível (base: `SpeechCutDeleteDialog.tsx`).
2. **Detalhe em estados não-prontos**: `uploading` (sem arquivo), `processing` (player já disponível? aviso de transcrição pendente), `failed` (copy de erro + "Reprocessar transcrição") — o artefato só desenha o detalhe Pronto.
3. **Reprocessar em andamento**: feedback do clique (botão desabilitado/spinner ou a tela trocar para Processando) e onde o erro do retry aparece.
4. **Erro acima do limite** no diálogo: posição/copy do erro inline e o aviso do limite antes do envio (o artefato promete "O limite será informado antes do envio" mas não o renderiza; o valor é "Vídeos até 4 GB").
5. **Progresso/estado do diálogo durante o envio**: a Cena 4 mostra só o formulário preenchido; se a barra da Cena 2 for portada para o diálogo, registrar como composição aprovada; senão desenhar.
6. **Paginação e vazio-da-busca da fonte nova**: reusar `CampaignListFooter` (shell existente) e definir a copy de "nenhuma gravação encontrada para X" (diferente do vazio Cena 5, que é "nenhuma gravação enviada ainda").
**Não é trigger (não inventa estrutura):** fiação de dados/copy no markup aprovado, reuso dos shells, destaque de trecho com `SpeechHighlightParts` (consistência com a lista da Câmara), rótulos de status do `lib/recording.ts`.
## Migration
- Nome sugerido: `pnpm migrate:create add_recording` → revisar o SQL gerado (`recording`, `recording_segment`, `recording_media`, enums `enum_recording_status`/`enum_recording_step`, FKs para `recording_media`/`campaign_user`, colunas em `payload_locked_documents_rels`, índices) e **editar à mão** os dois índices trigram, no padrão idempotente de `src/migrations/20260913_001200_add_speech_segment_trgm_index.ts:9-14`: `recording_search_text_trgm_idx` e `recording_segment_search_text_trgm_idx` (`pg_trgm` já instalado por migração anterior). Forma de referência: `20260918_201009_add_reel.ts`.
- **HARD-STOP de aprovação humana explícita, mesmo em sessão `--auto`, e local-only** (worktree, `pnpm migrate` no banco local; nunca tocar banco remoto/prod e nunca editar migração antiga). **Aprovada na sessão `--auto` (2026-09-18)** para criar/aplicar `add_recording` só no banco local do worktree.
## Invariantes
- Toda Local API em contexto de usuário com `overrideAccess: false` + `user` (leitura de lista/detalhe, media, retry, delete); bypass admin só onde o pipeline é dono do row/arquivo, com comentário justificando (forma de `speechCutJob.ts:42-83`).
- Escrita multi-collection em transação com `req` (`withPayloadTransaction`): finalizar upload (media+row), salvar transcrição (segmentos+row) e apagar (segmentos+row); o delete físico do `recordingMedia` roda pós-commit (`onPayloadTransactionCommit`) e é best-effort.
- Sem `Consent` novo (mídia interna de staff, sem PII); `Media.read` intocado; sem URL pública; `recordingMedia` fora do proxy `/api/media/file`.
- `leader`/`advisor` fail-closed em página (`gate: 'speechCatalog'`), access das collections e rota `/arquivo` (404 silencioso).
- Sem segundo cadastro de pessoa; transcrição somente leitura; nenhum item de nav novo; nenhum módulo top-level novo em `src/utilities/` (subpastas `recordings/`, `privateMedia/`, `media/`); pin de conventions/manifest atendidos.
- Identificadores em inglês; copy/labels em pt-BR; valores de URL em pt-BR onde o repo já usa (`source=enviadas`, `apagar`, `arquivo`, `enviar`; `retry` como no corte).
- "Edit the owner, don't twin": serving generalizado no módulo, `runFfmpeg` extraído, `pickMatchingSegment` movido ao lib; `Speech`/`SpeechSegment`/`Media`/`ReelMedia` intocados.
- `pnpm gate:fast` durante; `pnpm push` como entrega; e2e da superfície antes do push.
## Fases verificáveis
1. **Puro + schema + migration** (~1 dia).
- `src/lib/recording.ts`, `recordingTranscription.ts`, `privateMedia.ts`, `schemas/recording.ts`; collections + access + barrel; `payload.config.ts`; `pnpm generate:types`.
- Unit: statuses/limites/labels; merge de chunks (offset, clamp, dedupe, searchText); URL/parse/canonical do `source`; predicates (forma); headers puros movidos.
- Int: matriz de access das 3 collections (communicator/coordinator/candidate passam; advisor/leader/anônimo negados) e cascade de segmentos.
- **Migration `add_recording` — HARD-STOP humano, local-only.**
2. **Serving + upload + job** (~1,5 dia).
- `privateMedia/*` + troca da rota C193 (testes C193 verdes); rota `/arquivo`; upload (`enviar`) + allowlist; `deepInfraTranscribeSegments`; `media/ffmpeg.ts`; job + scheduler + reaper; retry/delete/status + actions.
- Unit: validação do metadata/limite; parser de Range (movidos); job puro.
- Int: upload (streaming para disco de teste, falha de tamanho/mime sem row fantasma, transição), job com `FFMPEG_PATH` fake + transcritor injetado (happy path multi-chunk com offsets; falha do provider → `failed` e arquivo preservado; retry reseta; reaper), `/arquivo` (206/416/404 e papéis), delete (segmentos+row+media), retry/status.
3. **UI** (~1 dia, após os complementos do designer).
- Toggle, lista+busca+footer+vazio, cards de estado, diálogo de envio (client, XHR + progresso), detalhe (player + transcrição clicável), refresher de status, reprocessar, apagar.
- Unit: view models (labels, excerpt, `watchHref` com `t`), toggle/aria, validação client-side de tamanho.
4. **E2E + gates** (~0,5 dia).
- Estender `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` (herda o acervo existente): fixture de `recording` `ready` + segmentos + `recordingMedia` pequeno; toggle/`?source=enviadas`; busca com excerpt e `?t=`; detalhe com player/transcrição/Baixar; `/arquivo` 200 para comunicador e 404 para advisor/anônimo; contrato HTTP do upload (cross-origin/limite). Upload+ASR reais ficam no int (declarado).
- Manifest/conventions; `pnpm gate:fast`; `pnpm test:e2e:affected`; `pnpm push`.
Quota: ~3,5–4 dias eng, dentro do appetite herdado; se apertar, o corte é a fase 3 (UI) para o essencial do artefato antes do polish.
## Rabbit holes / Não escopo (engenharia)
- Fila/worker/pipeline genérico, tempo real, progresso percentual por chunk (intenção cortou).
- Diarização/rotulagem por pessoa (C200); `Contact` nunca entra.
- Transcode, thumb real, normalização, legendas, mux (o placeholder do artefato serve).
- Upload resumível/chunked no browser, retomada de sessão, URLs assinadas S3, `clientUploads`.
- Merge das fontes, entrada no home-search, busca semântica (C192), cortes (C167/C174).
- Edição de transcrição, dossiês, publicação externa, comissões/outras fontes.
- Renomear `ReelMedia`/`Media`, rota pública nova, `Consent` novo, item de nav novo.
- Limpeza de mídia órfã fora do delete da própria gravação.
## Riscos e mitigação
- **Job morto por deploy/restart no meio de horas de transcrição.** Reaper de 60 min + heartbeat por chunk + retry que reprocessa do zero; estados honestos e arquivo preservado.
- **Disco efêmero do container com arquivo de vários GB.** Limite 4 GiB fail-closed (cliente e servidor), limpeza em `finally`, ENOSPC vira `failed` com mensagem mapeada; ops deve garantir headroom do container (nota de deploy).
- **Custo/limite do Deep Infra.** Chunks de 20 min (~4,8 MB, 32 kbps), timeout de 600 s por chunk, concatenação com offsets; falha isolada não publica transcrição parcial (uma transação só no fim).
- **LIKE casando na fronteira entre segmentos.** Fallback de excerpt por termos (`pickMatchingSegment` movido); nenhum resultado sem excerpt quando o `searchText` casou.
- **Chrome genérico engolindo `gravacoes` (lição C194).** Regra específica antes da genérica (`campaignPageChrome.ts:297`) + pin unit; alias `/acervo/gravacoes` redireciona.
- **Regressão do contrato Câmara.** `speechListUrl`/`speechListFilters`/`Speech` intocados; unit existente de URL/omnibox continua sendo o pino; o branch novo tem param set próprio.
- **Vazamento de mídia.** Access por papel em todas as camadas, rota sob `/campanha` com 404 silencioso, sem proxy público; matriz de papéis no int/e2e.
- **S3 não configurado em produção (`recordingMedia: true` esquecido).** Entra no aceite e no checklist do §Migration/config; `resolveS3StorageEnv` fail-closed no boot parcial.
- **e2e não cobre upload/ASR reais.** Decisão registrada: int cobre streaming e job (ffmpeg fake + transcritor injetado); e2e cobre a superfície HTTP/UI com fixture pronta.
## Débitos do /simplify (triage autônoma)
- **Registrado:** gates de upload/retry/delete por `canReadCommunicationCatalog` em vez dos predicados de escrita do dono → **C202** (#1186), `depends: [C199]`, plano `docs/plans/gates-gravacoes-predicados.md` (score 4, expensive_lock; destrava com `pnpm agent:ready` após este merge).
- **Defer (gatilho):** `withSeek` reimplementa o `?t=&q=` de `buildWatchHref` — extrair só quando houver 3º consumidor do deep-link (C200/corte de gravação) ou edição conjunta dos dois view models.
- **Defer (gatilho):** busca da lista com 1 query de segmento por resultado — revisitar se o p95 da busca passar de ~1,5 s ou o catálogo de gravações passar de ~500 (trocar por query única/lateral ou excerpt pré-computado).
- **Defer (gatilho):** job cria `recordingSegment` um a um — revisitar quando um save de gravação de horas passar de ~1–2 min (bulk insert).
- **Defer (gatilho):** reaper só roda em consulta de status/lista/detalhe — revisitar se existir varredura periódica de mídia/auditoria de bucket (D5 manteve sem fila).
- **Defer (gatilho):** headroom de disco/ENOSPC no container com vídeos de horas — conferir no runbook antes do primeiro upload real de horas (risco já mitigado por cap de 4 GiB + cleanup).
- **Descartado:** `privateMediaResponse.ts` fundir storage+serving+download — consolidação deliberada de um dono único para o mesmo I/O privado; reabrir só com 2º consumidor real de storage.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: enviar e acompanhar até Pronto; fonte nova pesquisável sem facetas; fala de qualquer pessoa com trecho+timestamps; detalhe com player/transcrição clicável/Baixar/Reprocessar em Falhou; estados visíveis; falha preserva o arquivo; só os três papéis; sem URL pública.
- [ ] Invariantes AGENTS/engineering-standards: `overrideAccess: false` + `user`; transação multi-collection; sem `Consent`; `Media.read` intocado; leader/advisor fail-closed; sem nav novo; sem top-level novo em `utilities/`; manifest e allowlist de conventions atualizados; migração nova sem editar antigas; `pnpm push`.
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: merges/offset e searchText; access das 3 collections + rota; upload/limite; job (feliz/falha/retry/reaper); serving com Range; delete em cascata.
## Self-score de decision-quality
**4,5/5.** As sete decisões caras têm alternativas honestas e rejeições ancoradas em file:line (contrato pinado da Câmara, `reelMedia` de domínio, endpoint interno do C193, ausência de parser no `package.json`, precedente do job/reaper). A abordagem cabe no appetite (schema→serving/job→UI→e2e, tracer cedo no upload+schema) e reusa shells/helpers/mecanismos existentes. Perde 0,5 porque duas escolhas ficam condicionadas a confirmação humana fora do código: a migração (HARD-STOP declarado) e os complementos visuais do designer (apagar, estados do detalhe, erro de limite, paginação) — sem eles o markup não fecha e o plano não pode fingir que o artefato cobre tudo.

View file

@ -29,9 +29,14 @@
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
button,
input {
input,
a {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
.icon {
width: 1rem;
height: 1rem;
@ -70,6 +75,16 @@
color: #292524;
border: 1px solid transparent;
}
.btn-destructive {
background: #b42318;
color: white;
border: 1px solid #b42318;
}
.btn:disabled,
.btn[aria-disabled='true'] {
cursor: not-allowed;
opacity: 0.55;
}
.field {
min-height: 2.75rem;
width: 100%;
@ -157,6 +172,11 @@
.seg-active {
background: #f4f4f5;
}
.alert-error {
border: 1px solid rgb(180 35 24 / 28%);
background: #fff7f6;
color: #8f1d14;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@ -213,6 +233,20 @@
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</symbol>
<symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M3 6h18M8 6V4h8v2M19 6l-1 15H6L5 6" />
<path d="M10 11v5M14 11v5" />
</symbol>
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</symbol>
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m15 18-6-6 6-6" />
</symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="m9 18 6-6-6-6" />
</symbol>
</svg>
<main class="mx-auto w-max max-w-none px-10 py-10">
@ -280,18 +314,18 @@
</div>
<div
class="mt-5 inline-flex rounded-lg bg-secondary p-1 text-sm"
role="tablist"
aria-label="Fonte do acervo"
>
<button class="rounded-md px-3 py-2 text-muted-foreground" data-source-tab="camara">
<a class="rounded-md px-3 py-2 text-muted-foreground" href="#scene-1">
Falas da Câmara
</button>
<button
</a>
<a
class="rounded-md bg-white px-3 py-2 font-medium text-foreground shadow-sm ring-1 ring-border"
data-source-tab="enviadas"
href="#scene-1"
aria-current="page"
>
Gravações enviadas
</button>
</a>
</div>
<label class="mt-5 block text-sm font-medium" for="search-1"
>Buscar nas gravações enviadas</label
@ -600,7 +634,7 @@
<svg class="h-6 w-6 text-muted-foreground"><use href="#i-upload" /></svg
><span class="mt-2 text-sm font-medium">Selecionar arquivo local</span
><span class="mt-1 text-xs text-muted-foreground"
>O limite será informado antes do envio.</span
>Vídeos até 4 GB · MP4, MOV, MKV e formatos compatíveis</span
>
</button>
</div>
@ -617,8 +651,8 @@
<label class="text-sm font-medium" for="date-upload">Data da gravação</label
><input
id="date-upload"
type="date"
class="field mt-1.5 text-sm text-muted-foreground"
placeholder="dd/mm/aaaa"
/>
</div>
</div>
@ -638,9 +672,13 @@
<div class="desktop-frame p-8">
<div class="mx-auto max-w-5xl">
<div class="inline-flex rounded-lg bg-secondary p-1 text-sm">
<span class="rounded-md px-3 py-2 text-muted-foreground">Falas da Câmara</span
><span class="rounded-md bg-white px-3 py-2 font-medium shadow-sm ring-1 ring-border"
>Gravações enviadas</span
<a class="rounded-md px-3 py-2 text-muted-foreground" href="#scene-5"
>Falas da Câmara</a
><a
class="rounded-md bg-white px-3 py-2 font-medium shadow-sm ring-1 ring-border"
href="#scene-5"
aria-current="page"
>Gravações enviadas</a
>
</div>
<div
@ -664,7 +702,7 @@
</section>
<!-- CENA 6 -->
<section aria-labelledby="scene-6">
<section class="mb-12" aria-labelledby="scene-6">
<h2 id="scene-6" class="scene-label">
Cena 6 · Mobile (390 px) — lista e detalhe com transcrição
</h2>
@ -680,12 +718,15 @@
</header>
<div class="border-b border-border bg-white px-4 pt-3 pb-2">
<div class="grid grid-cols-2 rounded-lg bg-secondary p-1 text-xs">
<button class="rounded-md px-2 py-2 text-muted-foreground">Falas da Câmara</button
><button
<a class="rounded-md px-2 py-2 text-center text-muted-foreground" href="#scene-6"
>Falas da Câmara</a
><a
class="rounded-md bg-white px-2 py-2 font-medium shadow-sm ring-1 ring-border"
href="#scene-6"
aria-current="page"
>
Gravações enviadas
</button>
</a>
</div>
<div class="mt-2 flex min-h-10 items-center gap-2 rounded-lg px-2">
<svg class="icon text-muted-foreground"><use href="#i-search" /></svg
@ -803,18 +844,614 @@
</div>
</div>
</section>
</main>
<script>
document.querySelectorAll('[data-source-tab]').forEach((tab) => {
tab.addEventListener('click', () => {
document
.querySelectorAll('[data-source-tab]')
.forEach((item) => (item.className = 'rounded-md px-3 py-2 text-muted-foreground'))
tab.className =
'rounded-md bg-white px-3 py-2 font-medium text-foreground shadow-sm ring-1 ring-border'
})
})
</script>
<!-- CENA 7 -->
<section class="mb-12" aria-labelledby="scene-7">
<h2 id="scene-7" class="scene-label">
Cena 7 · Desktop — confirmação destrutiva ao apagar
</h2>
<div class="desktop-frame relative overflow-hidden bg-stone-50">
<div class="border-b border-border bg-white px-6 py-5">
<button class="btn btn-ghost -ml-2">
<svg class="icon"><use href="#i-back" /></svg>Voltar ao acervo
</button>
<div class="mt-3 flex items-center justify-between gap-4">
<div>
<h3 class="text-lg font-semibold">Título da gravação</h3>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
</div>
<button class="btn btn-ghost text-destructive">
<svg class="icon"><use href="#i-trash" /></svg>Apagar
</button>
</div>
</div>
<div class="absolute inset-0 bg-stone-950/35"></div>
<section
class="absolute top-1/2 left-1/2 w-[min(31rem,calc(100%-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-white p-6 shadow-2xl"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-title"
aria-describedby="delete-description"
>
<div class="flex items-start gap-3">
<span
class="grid size-9 shrink-0 place-items-center rounded-full bg-red-50 text-destructive"
>
<svg class="h-5 w-5"><use href="#i-alert" /></svg>
</span>
<div class="min-w-0">
<h3 id="delete-title" class="text-base font-semibold">Apagar esta gravação?</h3>
<p id="delete-description" class="mt-2 text-sm leading-5 text-muted-foreground">
O arquivo e toda a transcrição serão removidos. Esta ação não pode ser desfeita.
</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-2 max-sm:flex-col-reverse">
<button class="btn btn-outline max-sm:w-full">Cancelar</button>
<button class="btn btn-destructive max-sm:w-full">
<svg class="icon"><use href="#i-trash" /></svg>Apagar gravação
</button>
</div>
</section>
</div>
<div class="mt-3 rounded-lg border border-border bg-white p-4 text-sm leading-6">
<strong>Decisão visual:</strong> o gatilho “Apagar” vive somente no detalhe, no bloco de
ações do cabeçalho. Os cards da lista preservam uma única ação, “Abrir”, evitando uma ação
irreversível no contexto de varredura.
</div>
</section>
<!-- CENA 8 -->
<section class="mb-12" aria-labelledby="scene-8">
<h2 id="scene-8" class="scene-label">
Cena 8 · Desktop — detalhe em Enviando, Processando e Falhou
</h2>
<div class="desktop-frame p-7">
<div class="grid grid-cols-3 gap-5">
<article class="min-w-0 rounded-xl border border-border bg-white p-5">
<div class="flex items-start justify-between gap-3">
<div>
<span class="status status-upload">Enviando</span>
<h3 class="mt-3 text-sm font-semibold">Título da gravação</h3>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa</p>
</div>
<button class="btn btn-ghost min-h-9 px-2 text-destructive">Apagar</button>
</div>
<div
class="mt-5 flex aspect-video flex-col items-center justify-center rounded-lg border border-dashed border-input bg-stone-50 px-5 text-center"
>
<svg class="h-7 w-7 text-muted-foreground"><use href="#i-upload" /></svg>
<p class="mt-3 text-sm font-medium">O arquivo ainda está sendo enviado</p>
<p class="mt-1 text-xs leading-5 text-muted-foreground">
Player e download aparecem quando o envio terminar.
</p>
</div>
<div class="mt-4 h-2 overflow-hidden rounded-full bg-muted">
<div class="h-full w-[58%] rounded-full bg-primary"></div>
</div>
<div class="mt-2 flex justify-between text-xs text-muted-foreground">
<span>Enviando arquivo…</span><span>58%</span>
</div>
</article>
<article class="min-w-0 rounded-xl border border-border bg-white p-5">
<div class="flex items-start justify-between gap-3">
<div>
<span class="status status-work">Processando</span>
<h3 class="mt-3 text-sm font-semibold">Título da gravação</h3>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
</div>
<button class="btn btn-ghost min-h-9 px-2 text-destructive">Apagar</button>
</div>
<div
class="relative mt-5 grid aspect-video place-items-center rounded-lg bg-stone-900"
>
<button
class="grid size-12 place-items-center rounded-full bg-white text-stone-950"
>
<svg class="h-5 w-5 translate-x-0.5"><use href="#i-play" /></svg>
</button>
</div>
<button class="btn btn-outline mt-3 w-full">
<svg class="icon"><use href="#i-download" /></svg>Baixar arquivo
</button>
<div class="mt-4 rounded-lg bg-amber-50 p-3 text-xs leading-5 text-amber-900">
<div class="flex items-center gap-2 font-semibold">
<span
class="size-4 animate-spin rounded-full border-2 border-amber-300 border-t-amber-800 motion-reduce:animate-none"
></span>
Transcrição em processamento
</div>
<p class="mt-1">
O vídeo já pode ser assistido. A transcrição ainda não está disponível.
</p>
</div>
</article>
<article class="min-w-0 rounded-xl border border-destructive/30 bg-white p-5">
<div class="flex items-start justify-between gap-3">
<div>
<span class="status status-failed">Falhou</span>
<h3 class="mt-3 text-sm font-semibold">Título da gravação</h3>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
</div>
<button class="btn btn-ghost min-h-9 px-2 text-destructive">Apagar</button>
</div>
<div
class="relative mt-5 grid aspect-video place-items-center rounded-lg bg-stone-900"
>
<button
class="grid size-12 place-items-center rounded-full bg-white text-stone-950"
>
<svg class="h-5 w-5 translate-x-0.5"><use href="#i-play" /></svg>
</button>
</div>
<div class="alert-error mt-4 rounded-lg p-3 text-xs leading-5">
<p class="font-semibold">A transcrição não foi concluída</p>
<p class="mt-1">Não foi possível processar o áudio. O arquivo foi preservado.</p>
</div>
<div class="mt-3 grid gap-2">
<button class="btn btn-primary w-full">
<svg class="icon"><use href="#i-refresh" /></svg>Reprocessar transcrição
</button>
<button class="btn btn-outline w-full">
<svg class="icon"><use href="#i-download" /></svg>Baixar arquivo
</button>
</div>
</article>
</div>
</div>
<p class="mt-3 max-w-4xl text-sm leading-6 text-muted-foreground">
Em “Enviando”, não há arquivo utilizável. Em “Processando” e “Falhou”, o envio já
terminou: player e download ficam disponíveis; somente a transcrição é substituída pelo
aviso de estado.
</p>
</section>
<!-- CENA 9 -->
<section class="mb-12" aria-labelledby="scene-9">
<h2 id="scene-9" class="scene-label">
Cena 9 · Desktop — reprocessamento em andamento e falha ao iniciar
</h2>
<div class="desktop-frame p-8">
<div class="mx-auto grid max-w-4xl grid-cols-2 gap-6">
<article class="rounded-xl border border-border p-5">
<span class="status status-work">Processando</span>
<h3 class="mt-3 text-base font-semibold">Reprocessando a transcrição</h3>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
O arquivo foi preservado. Você pode assistir enquanto a nova transcrição é
preparada.
</p>
<button class="btn btn-outline mt-5 w-full" disabled>
<span
class="size-4 animate-spin rounded-full border-2 border-stone-300 border-t-stone-700 motion-reduce:animate-none"
></span>
Reprocessando…
</button>
<p class="mt-3 text-center text-xs text-muted-foreground">
Ao aceitar o pedido, a tela troca de “Falhou” para “Processando”.
</p>
</article>
<article class="rounded-xl border border-destructive/30 p-5">
<span class="status status-failed">Falhou</span>
<h3 class="mt-3 text-base font-semibold">A transcrição continua pendente</h3>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
O erro original continua visível até uma nova tentativa ser iniciada.
</p>
<button class="btn btn-primary mt-5 w-full">
<svg class="icon"><use href="#i-refresh" /></svg>Reprocessar transcrição
</button>
<div
class="alert-error mt-3 flex gap-2 rounded-lg p-3 text-xs leading-5"
role="alert"
>
<svg class="mt-0.5 h-4 w-4 shrink-0"><use href="#i-alert" /></svg>
<span>Não foi possível iniciar o reprocessamento. Tente novamente.</span>
</div>
</article>
</div>
</div>
<p class="mt-3 text-sm leading-6 text-muted-foreground">
O erro do clique aparece imediatamente abaixo da ação, sem apagar a mensagem da falha de
transcrição. Não usar toast como único feedback.
</p>
</section>
<!-- CENA 10 -->
<section class="mb-12" aria-labelledby="scene-10">
<h2 id="scene-10" class="scene-label">
Cena 10 · Desktop — arquivo acima do limite no diálogo
</h2>
<div class="desktop-frame relative grid place-items-center bg-stone-200/70 p-8">
<div class="absolute inset-0 bg-stone-950/25"></div>
<section
class="relative w-full max-w-lg rounded-xl border border-border bg-white p-6 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="upload-error-title"
>
<div class="flex items-start justify-between">
<div>
<h3 id="upload-error-title" class="text-base font-semibold">Enviar gravação</h3>
<p class="mt-1 text-sm text-muted-foreground">
Adicione um arquivo local ao acervo.
</p>
</div>
<button
class="grid size-8 place-items-center rounded-md hover:bg-muted"
aria-label="Fechar"
>
<svg class="icon"><use href="#i-x" /></svg>
</button>
</div>
<div class="mt-5">
<label class="text-sm font-medium">Arquivo de vídeo</label>
<button
class="mt-1.5 flex min-h-28 w-full flex-col items-center justify-center rounded-lg border border-destructive bg-red-50/40 px-4 text-center"
>
<svg class="h-6 w-6 text-destructive"><use href="#i-file" /></svg>
<span class="mt-2 text-sm font-medium">gravacao-plenaria.mkv</span>
<span class="mt-1 text-xs text-muted-foreground">4,6 GB</span>
</button>
<div
class="alert-error mt-2 flex gap-2 rounded-lg p-3 text-xs leading-5"
role="alert"
>
<svg class="mt-0.5 h-4 w-4 shrink-0"><use href="#i-alert" /></svg>
<span>Este arquivo tem 4,6 GB. Escolha um vídeo de até 4 GB.</span>
</div>
<p class="mt-2 text-xs text-muted-foreground">
Vídeos até 4 GB · MP4, MOV, MKV e formatos compatíveis
</p>
</div>
<div class="mt-5 space-y-3 opacity-60" aria-hidden="true">
<div>
<span class="text-sm font-medium">Título</span>
<div class="mt-1.5 h-11 rounded-lg border border-input bg-white"></div>
</div>
<div>
<span class="text-sm font-medium">Data da gravação</span>
<div class="mt-1.5 h-11 rounded-lg border border-input bg-white"></div>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button class="btn btn-outline">Cancelar</button>
<button class="btn btn-primary" disabled>Enviar</button>
</div>
</section>
</div>
<p class="mt-3 text-sm leading-6 text-muted-foreground">
O limite aparece antes da seleção, dentro da área do arquivo. Quando excedido, o erro fica
logo abaixo do seletor, associa-se ao campo e bloqueia “Enviar”.
</p>
</section>
<!-- CENA 11 -->
<section class="mb-12" aria-labelledby="scene-11">
<h2 id="scene-11" class="scene-label">Cena 11 · Desktop — diálogo durante o envio</h2>
<div class="desktop-frame relative grid place-items-center bg-stone-200/70 p-8">
<div class="absolute inset-0 bg-stone-950/25"></div>
<section
class="relative w-full max-w-lg rounded-xl border border-border bg-white p-6 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="upload-progress-title"
>
<div class="flex items-start justify-between">
<div>
<h3 id="upload-progress-title" class="text-base font-semibold">
Enviando gravação
</h3>
<p class="mt-1 text-sm text-muted-foreground">
Mantenha esta janela aberta até concluir.
</p>
</div>
<button
class="grid size-8 cursor-not-allowed place-items-center rounded-md opacity-40"
aria-label="Fechar indisponível durante o envio"
disabled
>
<svg class="icon"><use href="#i-x" /></svg>
</button>
</div>
<div class="mt-5 rounded-lg border border-border bg-stone-50 p-4">
<div class="flex items-center gap-3">
<span
class="grid size-10 shrink-0 place-items-center rounded-lg bg-white text-muted-foreground ring-1 ring-border"
>
<svg class="h-5 w-5"><use href="#i-video" /></svg>
</span>
<div class="min-w-0">
<p class="truncate text-sm font-medium">gravacao-plenaria.mkv</p>
<p class="mt-0.5 text-xs text-muted-foreground">
Título da gravação · dd/mm/aaaa
</p>
</div>
<span class="ml-auto text-sm font-semibold tabular-nums">58%</span>
</div>
<div class="mt-4 h-2 overflow-hidden rounded-full bg-muted" aria-label="58% enviado">
<div class="h-full w-[58%] rounded-full bg-primary"></div>
</div>
<div class="mt-2 flex justify-between text-xs text-muted-foreground">
<span>Enviando arquivo…</span><span>58%</span>
</div>
</div>
<div class="mt-4 rounded-lg bg-muted/70 p-3 text-xs leading-5 text-muted-foreground">
Quando o envio terminar, a gravação aparecerá na lista como “Processando”.
</div>
<div class="mt-6 flex justify-end">
<button class="btn btn-primary min-w-36" disabled>
<span
class="size-4 animate-spin rounded-full border-2 border-white/45 border-t-white motion-reduce:animate-none"
></span>
Enviando…
</button>
</div>
</section>
</div>
</section>
<!-- CENA 12 -->
<section class="mb-12" aria-labelledby="scene-12">
<h2 id="scene-12" class="scene-label">Cena 12 · Desktop — paginação e vazio da busca</h2>
<div class="desktop-frame p-8">
<div class="mx-auto grid max-w-5xl grid-cols-2 gap-8">
<article>
<p class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
Rodapé com resultados
</p>
<div class="mt-4 space-y-3">
<div class="h-20 rounded-xl border border-border bg-white"></div>
<div class="h-20 rounded-xl border border-border bg-white"></div>
<div class="h-20 rounded-xl border border-border bg-white"></div>
</div>
<div class="mt-5 flex flex-col items-center gap-2">
<p class="text-sm text-muted-foreground">— gravações encontradas</p>
<nav class="flex items-center gap-1" aria-label="Paginação das gravações">
<a class="btn btn-ghost min-h-11 min-w-11 px-2" href="#scene-12">
<svg class="icon"><use href="#i-chevron-left" /></svg><span>Anterior</span>
</a>
<a class="btn btn-ghost min-h-11 min-w-11" href="#scene-12">1</a>
<a class="btn btn-outline min-h-11 min-w-11" href="#scene-12" aria-current="page"
>2</a
>
<a class="btn btn-ghost min-h-11 min-w-11" href="#scene-12">3</a>
<span class="grid min-h-11 min-w-11 place-items-center text-muted-foreground"
>…</span
>
<a class="btn btn-ghost min-h-11 min-w-11 px-2" href="#scene-12">
<span>Próxima</span><svg class="icon"><use href="#i-chevron-right" /></svg>
</a>
</nav>
</div>
</article>
<article>
<p class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
Busca sem resultado
</p>
<div
class="mt-4 flex min-h-[21rem] flex-col items-center justify-center rounded-xl border border-border px-6 text-center"
>
<span
class="grid size-12 place-items-center rounded-lg bg-muted text-muted-foreground"
>
<svg class="h-6 w-6"><use href="#i-search" /></svg>
</span>
<h3 class="mt-4 text-base font-semibold">
Nenhuma gravação encontrada para “termo buscado”
</h3>
<p class="mt-1 max-w-sm text-sm leading-5 text-muted-foreground">
Tente outro termo ou limpe a busca para ver todas as gravações enviadas.
</p>
<button class="btn btn-outline mt-5">Limpar busca</button>
</div>
</article>
</div>
</div>
<p class="mt-3 text-sm leading-6 text-muted-foreground">
Este vazio substitui somente os resultados filtrados e não oferece novo envio. O vazio sem
gravações continua sendo a Cena 5, com a ação “Enviar gravação”.
</p>
</section>
<!-- CENA 13 -->
<section class="mb-12" aria-labelledby="scene-13">
<h2 id="scene-13" class="scene-label">
Cena 13 · Mobile (390 px) — extensão dos estados críticos
</h2>
<div class="flex items-start gap-8">
<div>
<p class="mb-2 text-xs font-medium text-muted-foreground">Detalhe com falha</p>
<div class="mobile-frame relative pb-20">
<header class="flex min-h-14 items-center gap-3 bg-primary px-4 text-white">
<svg class="icon"><use href="#i-back" /></svg>
<div>
<h3 class="text-sm font-semibold">Gravação enviada</h3>
<p class="text-xs text-white/75">Acervo</p>
</div>
</header>
<div class="p-4">
<div class="flex items-start justify-between gap-3">
<div>
<h4 class="text-base font-semibold">Título da gravação</h4>
<p class="mt-1 text-xs text-muted-foreground">dd/mm/aaaa · —:—:—</p>
</div>
<span class="status status-failed">Falhou</span>
</div>
<div
class="relative mt-4 grid aspect-video place-items-center rounded-lg bg-stone-900"
>
<button
class="grid size-12 place-items-center rounded-full bg-white text-stone-950"
>
<svg class="h-5 w-5 translate-x-0.5"><use href="#i-play" /></svg>
</button>
</div>
<div class="alert-error mt-4 rounded-lg p-3 text-xs leading-5">
<p class="font-semibold">A transcrição não foi concluída</p>
<p class="mt-1">Não foi possível processar o áudio. O arquivo foi preservado.</p>
</div>
<button class="btn btn-primary mt-3 w-full">
<svg class="icon"><use href="#i-refresh" /></svg>Reprocessar transcrição
</button>
<button class="btn btn-outline mt-2 w-full">
<svg class="icon"><use href="#i-download" /></svg>Baixar arquivo
</button>
<button class="btn btn-ghost mt-2 w-full text-destructive">
<svg class="icon"><use href="#i-trash" /></svg>Apagar
</button>
</div>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted-foreground">Confirmação de apagar</p>
<div class="mobile-frame relative grid place-items-center bg-stone-200 p-4">
<div class="absolute inset-0 bg-stone-950/35"></div>
<section
class="relative w-full rounded-xl border border-border bg-white p-5 shadow-2xl"
role="alertdialog"
aria-modal="true"
aria-labelledby="mobile-delete-title"
>
<span
class="grid size-9 place-items-center rounded-full bg-red-50 text-destructive"
>
<svg class="h-5 w-5"><use href="#i-alert" /></svg>
</span>
<h3 id="mobile-delete-title" class="mt-4 text-base font-semibold">
Apagar esta gravação?
</h3>
<p class="mt-2 text-sm leading-5 text-muted-foreground">
O arquivo e toda a transcrição serão removidos. Esta ação não pode ser desfeita.
</p>
<div class="mt-6 flex flex-col-reverse gap-2">
<button class="btn btn-outline w-full">Cancelar</button>
<button class="btn btn-destructive w-full">
<svg class="icon"><use href="#i-trash" /></svg>Apagar gravação
</button>
</div>
</section>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted-foreground">Envio em andamento</p>
<div class="mobile-frame relative grid place-items-center bg-stone-200 p-4">
<div class="absolute inset-0 bg-stone-950/25"></div>
<section
class="relative w-full rounded-xl border border-border bg-white p-5 shadow-2xl"
>
<h3 class="text-base font-semibold">Enviando gravação</h3>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
Mantenha esta janela aberta até concluir.
</p>
<div class="mt-5 rounded-lg border border-border bg-stone-50 p-4">
<div class="flex items-center gap-3">
<svg class="h-5 w-5 shrink-0 text-muted-foreground">
<use href="#i-video" />
</svg>
<p class="min-w-0 flex-1 truncate text-sm font-medium">gravacao-plenaria.mkv</p>
<span class="text-sm font-semibold">58%</span>
</div>
<div class="mt-4 h-2 overflow-hidden rounded-full bg-muted">
<div class="h-full w-[58%] rounded-full bg-primary"></div>
</div>
<p class="mt-2 text-xs text-muted-foreground">Enviando arquivo…</p>
</div>
<div
class="mt-4 rounded-lg bg-muted/70 p-3 text-xs leading-5 text-muted-foreground"
>
Depois do envio, a gravação aparecerá como “Processando”.
</div>
<button class="btn btn-primary mt-5 w-full" disabled>
<span
class="size-4 animate-spin rounded-full border-2 border-white/45 border-t-white motion-reduce:animate-none"
></span>
Enviando…
</button>
</section>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted-foreground">Busca sem resultado</p>
<div class="mobile-frame relative pb-20">
<header class="flex min-h-14 items-center bg-primary px-4 text-white">
<h3 class="text-sm font-semibold">Acervo</h3>
</header>
<div class="p-4">
<div class="grid grid-cols-2 rounded-lg bg-secondary p-1 text-xs">
<a
class="rounded-md px-2 py-2 text-center text-muted-foreground"
href="#scene-13"
>
Falas da Câmara
</a>
<a
class="rounded-md bg-white px-2 py-2 text-center font-medium shadow-sm ring-1 ring-border"
href="#scene-13"
aria-current="page"
>
Gravações enviadas
</a>
</div>
<div
class="mt-4 flex min-h-80 flex-col items-center justify-center rounded-xl border border-border px-5 text-center"
>
<span
class="grid size-12 place-items-center rounded-lg bg-muted text-muted-foreground"
>
<svg class="h-6 w-6"><use href="#i-search" /></svg>
</span>
<h4 class="mt-4 text-base font-semibold">
Nenhuma gravação encontrada para “termo buscado”
</h4>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
Tente outro termo ou limpe a busca.
</p>
<button class="btn btn-outline mt-5 w-full">Limpar busca</button>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ANOTAÇÕES -->
<section class="mb-4" aria-labelledby="design-notes">
<h2 id="design-notes" class="scene-label">Anotações semânticas obrigatórias para o port</h2>
<div
class="grid grid-cols-2 gap-4 rounded-xl border border-border bg-white p-5 text-sm leading-6"
>
<ul class="list-disc space-y-2 pl-5">
<li>
O alternador de fonte é <strong>navegação</strong>: dois links com
<code class="rounded bg-muted px-1 py-0.5 text-xs">aria-current="page"</code> no
ativo, mantendo a aparência de pill. Não é widget de abas.
</li>
<li>
“Data da gravação” usa o controle nativo
<code class="rounded bg-muted px-1 py-0.5 text-xs">type="date"</code>.
</li>
<li>Todo texto visível, rótulo, feedback e mensagem de erro permanece em pt-BR.</li>
</ul>
<ul class="list-disc space-y-2 pl-5">
<li>
Toda ação destrutiva exige confirmação. “Apagar” aparece somente no detalhe e abre a
Cena 7; nunca apaga diretamente no card.
</li>
<li>
Um CTA domina por estado: “Baixar” em Pronto; “Reprocessar transcrição” em Falhou;
“Enviar” no formulário válido; “Apagar gravação” apenas dentro da confirmação.
</li>
<li>
Erros de campo ficam junto ao campo; erro de retry fica sob a ação; falha de
transcrição ocupa o painel do estado. Toast nunca é a única evidência.
</li>
</ul>
</div>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,43 @@
# C202 — Gates de upload/retry/delete de gravações usam os predicados do dono
Status: rascunho
Atualizado em: 2026-09-18
Issue: C202
Depende de: C199 (#1166)
Priority: P2
Impeccable: A (só backend)
Appetite: ~0,5 dia eng — trocar o gate de leitura pelos predicados de escrita onde a ação é escrita
## Contexto
O C199 entregou `src/utilities/access/recordings.ts` com três predicados: `canReadRecording`, `canUploadRecording` e `canDeleteRecording` — os dois últimos existem justamente para que ampliar a leitura não conceda upload/delete. Mas as rotas e ações de gravação gateiam por `canReadCommunicationCatalog(actor.role)` (o predicado de papel da vertical), que hoje é equivalente aos três. O desvio é silencioso: uma futura ampliação da leitura (ex.: `advisor` ganha leitura do acervo) passaria a valer para upload/retry/delete sem tocar no dono.
## Abordagem
Expor no dono (`utilities/access/recordings.ts`) um predicado de papel compartilhado (ou usar os `Access` existentes via um helper de rota que receba o usuário fresco), e consumi-lo nas superfícies de escrita:
- `POST .../gravacoes/enviar` (`route.ts` — gate cru de sessão e origem).
- `retryRecordingForActor`, `deleteRecordingForActor` (`actions/recording.ts`).
- `getRecordingStatusesForActor` é leitura: mantém `canReadCommunicationCatalog`.
Rejeitadas: duplicar o corpo dos predicados nas rotas (gêmeo); criar um quarto predicado (o dono já tem os três).
## Fases
1. Helper/consumo no dono + rotas/ações; unit dos predicados e int de matriz de papéis (communicator/coordinator/candidate passam; advisor/leader negados) já existentes no `tests/int/recording.int.spec.ts` continuam a rede.
2. Gates — `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo
- Renomear `canReadCommunicationCatalog` (fora: o predicado é da vertical e está estável).
- Enforcement por coleção (já existe: os `Access` das três collections).
- Mudança de comportamento visível: nenhuma esperada hoje (os conjuntos coincidem).
## Riscos
- Divergência futura coberta por teste de matriz de papéis no int, que passa a apontar o predicado de escrita.
## Aceite de engenharia
- [ ] Upload/retry/delete gateados pelo predicado de escrita do dono; status/leitura pelo de leitura.
- [ ] Matriz de papéis verde no int; nenhuma regressão de comportamento.

View file

@ -259,8 +259,10 @@ export const E2E_AFFECTED_MANIFEST = [
specs: ['campaignPermissionProfileHttp', 'campaignDemandVisibility'],
},
{
// C199 — the recordings' timestamped ASR uses the same provider owner; the
// acervo spec exercises the recordings surface the job feeds.
prefixes: ['src/utilities/ai'],
specs: ['campaignAiTranscribe'],
specs: ['campaignAiTranscribe', 'campaignSpeechAcervo'],
},
{
// Zod input schemas surface in the browser through form flows; the
@ -316,10 +318,18 @@ export const E2E_AFFECTED_MANIFEST = [
`${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',
'src/utilities/speech',
'src/utilities/reels',
// C199 — the shared private-media owner and the ffmpeg runner.
'src/utilities/recordings',
'src/utilities/privateMedia',
'src/utilities/media',
'src/lib/speech',
'src/lib/reel',
'src/lib/recording',
'src/lib/privateMedia',
// The role predicates drive the vertical gate and the assistant surfaces.
'src/lib/campaignRoles',
],

View file

@ -12,12 +12,12 @@ import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { parseSeekSeconds } from '@/lib/speechClock'
import { isExcerptSelectionAvailable } from '@/lib/speechExcerptSelection'
import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadSpeechCutsForSpeech } from '@/utilities/speech/speechCutPageData'
import { loadSpeechDetailPageData, SpeechNotFoundError } from '@/utilities/speech/speechPageData'
import { parseSpeechSeekSeconds } from '@/utilities/speech/speechViewModels'
type SpeechDetailPageProps = {
params: Promise<{ id: string }>
@ -56,7 +56,7 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
if (!speechId) notFound()
const q = firstValue(query.q)
const initialSeconds = parseSpeechSeekSeconds(firstValue(query.t))
const initialSeconds = parseSeekSeconds(firstValue(query.t))
const [view, cuts] = await Promise.all([
loadSpeechDetailPageData(payload, user, speechId, q).catch((error) => {

View file

@ -0,0 +1,60 @@
import { NextResponse } from 'next/server'
import { deleteRecordingForActor } from '@/app/(campaign)/campanha/actions/recording'
import {
RECORDING_FORBIDDEN_MESSAGE,
RECORDING_GENERIC_ERROR_MESSAGE,
RECORDING_NOT_FOUND_MESSAGE,
} from '@/lib/schemas/recording'
import {
CAMPAIGN_JSON_INVALID_REQUEST_MESSAGE,
campaignJsonMutationErrorResponse,
} from '@/utilities/campaignJsonMutationRoute'
import { strictDecimalInteger } from '@/utilities/campaignListUrl'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import type { RecordingDeleteResponse } from '../../types'
type RouteContext = {
params: Promise<{ id: string }>
}
export const dynamic = 'force-dynamic'
/**
* C199 — hard deletes one recording (row + transcript + private media). It
* cannot ride `campaignJsonMutationRoute` (that wrapper builds POST and parses
* a JSON body; the id is in the path), so it repeats the same-origin guard and
* error envelope explicitly. The `codebaseConventions` sweep pins the
* `isSameOriginRequest` call for every DELETE route.
*/
export const DELETE = async (
request: Request,
context: RouteContext,
): Promise<NextResponse<RecordingDeleteResponse>> => {
if (!isSameOriginRequest(request)) {
return NextResponse.json(
{ status: 'error', message: CAMPAIGN_JSON_INVALID_REQUEST_MESSAGE },
{ status: 403 },
)
}
const { id } = await context.params
const recordingId = strictDecimalInteger(id)
if (!recordingId) {
return NextResponse.json(
{ status: 'error', message: RECORDING_NOT_FOUND_MESSAGE },
{ status: 400 },
)
}
try {
await deleteRecordingForActor({ recordingId })
return NextResponse.json<RecordingDeleteResponse>({ status: 'success', deleted: true })
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: [RECORDING_FORBIDDEN_MESSAGE, RECORDING_NOT_FOUND_MESSAGE],
genericMessage: RECORDING_GENERIC_ERROR_MESSAGE,
})
}
}

View file

@ -0,0 +1,68 @@
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
import { canServeRecordingMedia, isRecordingStatus, RECORDING_MEDIA_SLUG } from '@/lib/recording'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { buildPrivateMediaResponse } from '@/utilities/privateMedia/privateMediaResponse'
export const dynamic = 'force-dynamic'
/**
* C199 — the only door to a recording file. Lives under `/campanha` because the
* `campaign-token` cookie is scoped to that path (a `<video>` sends the cookie,
* never an Authorization header). The gate is the same communication catalog
* predicate as the collection access; only a recording whose upload finished
* serves its file, and every denial is a silent `404` so the route never leaks
* which recordings exist.
*/
const notFound = (): NextResponse =>
new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL },
})
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 { id } = await params
const recordingId = Number(id)
if (!Number.isInteger(recordingId) || recordingId <= 0) return notFound()
const payload = await getPayload({ config })
const recording = await payload
.findByID({
collection: 'recording',
id: recordingId,
depth: 1,
select: { status: true, media: true },
user,
overrideAccess: false,
})
.catch(() => null)
if (!recording || !isRecordingStatus(recording.status)) return notFound()
if (!canServeRecordingMedia(recording.status)) return notFound()
const media = recording.media
if (!media || typeof media !== 'object') return notFound()
const upload = payload.collections[RECORDING_MEDIA_SLUG].config.upload
const staticDir =
upload && typeof upload === 'object' && upload.staticDir
? upload.staticDir
: RECORDING_MEDIA_SLUG
return buildPrivateMediaResponse({
media,
staticDir,
rangeHeader: request.headers.get('range'),
download: new URL(request.url).searchParams.get('download') === '1',
})
}

View file

@ -0,0 +1,134 @@
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 { RecordingDeleteDialog } from '@/components/campaign/recording/RecordingDeleteDialog'
import { RecordingDetailPlayer } from '@/components/campaign/recording/RecordingDetailPlayer'
import { RecordingStatusBadge } from '@/components/campaign/recording/RecordingStatusBadge'
import { RecordingStatusRefresher } from '@/components/campaign/recording/RecordingStatusRefresher'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { parseSeekSeconds } from '@/lib/speechClock'
import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { buildAcervoSourceHref } from '@/utilities/recordings/recordingListUrl'
import {
loadRecordingDetailPageData,
loadRecordingTitleForActor,
RecordingNotFoundError,
} from '@/utilities/recordings/recordingPageData'
type RecordingDetailPageProps = {
params: Promise<{ id: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export async function generateMetadata({ params }: RecordingDetailPageProps) {
const { id } = await params
const recordingId = strictDecimalInteger(id)
if (!recordingId) return campaignPageMetadata({ title: 'Gravação enviada' })
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
try {
const title = await loadRecordingTitleForActor(payload, user, recordingId)
return campaignPageMetadata({ title: title ?? 'Gravação enviada' })
} catch {
return campaignPageMetadata({ title: 'Gravação enviada' })
}
}
/**
* C199 — one uploaded recording: the private player, the clickable transcript,
* the download, the retry of a failed transcription and the delete confirmation
* (the only place delete lives).
*/
export default async function RecordingDetailPage({
params,
searchParams,
}: RecordingDetailPageProps) {
const [{ id }, query, user, payload] = await Promise.all([
params,
searchParams,
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
const recordingId = strictDecimalInteger(id)
if (!recordingId) notFound()
const recording = await loadRecordingDetailPageData(
payload,
user,
recordingId,
firstValue(query.q)?.trim() || undefined,
).catch((error) => {
if (error instanceof RecordingNotFoundError) notFound()
throw error
})
const initialSeconds = parseSeekSeconds(firstValue(query.t))
const metaLine = [recording.recordedAtLabel ?? 'Sem data', recording.durationLabel]
.filter(Boolean)
.join(' · ')
return (
<CampaignPageShell>
<SetCampaignPageChrome chrome={{ title: 'Gravação enviada', subtitle: 'Acervo' }} />
<RecordingStatusRefresher recordings={[{ id: recording.id, status: recording.status }]} />
<div className="flex flex-col gap-4">
<Button asChild variant="ghost" className="-ml-2 min-h-11 self-start">
<Link href={buildAcervoSourceHref('enviadas')}>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar ao acervo
</Link>
</Button>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
{/* Title first, date below, status lateral — the approved hierarchy
(scenes 3 and 13). */}
<div className="min-w-0">
<h1 className="text-lg font-medium">{recording.title}</h1>
<p className="mt-1 text-xs text-muted-foreground">{metaLine}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<RecordingStatusBadge status={recording.status} />
<div className="hidden md:block">
<RecordingDeleteDialog
recordingId={recording.id}
redirectTo={buildAcervoSourceHref('enviadas')}
/>
</div>
</div>
</div>
<RecordingDetailPlayer
recordingId={recording.id}
status={recording.status}
fileHref={recording.fileHref}
downloadHref={recording.downloadHref}
failureMessage={recording.failureMessage}
segments={recording.segments}
initialSeconds={initialSeconds}
/>
{/* Mobile keeps the destructive action after the player and the
download, as a ghost action (approved scene 13). */}
<div className="md:hidden">
<RecordingDeleteDialog
recordingId={recording.id}
redirectTo={buildAcervoSourceHref('enviadas')}
triggerClassName="w-full"
/>
</div>
</div>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { retryRecordingForActor } from '@/app/(campaign)/campanha/actions/recording'
import {
RECORDING_FORBIDDEN_MESSAGE,
RECORDING_GENERIC_ERROR_MESSAGE,
RECORDING_NOT_FOUND_MESSAGE,
RECORDING_RETRY_NOT_FAILED_MESSAGE,
recordingRetryRequestSchema,
} from '@/lib/schemas/recording'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { RecordingRetryResponse } from '../../types'
export const dynamic = 'force-dynamic'
/** C199 — retries a failed transcription from the detail (same row, same job). */
export const POST = campaignJsonMutationRoute(
{
bodySchema: recordingRetryRequestSchema,
safeMessages: [
RECORDING_FORBIDDEN_MESSAGE,
RECORDING_NOT_FOUND_MESSAGE,
RECORDING_RETRY_NOT_FAILED_MESSAGE,
],
genericMessage: RECORDING_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const recording = await retryRecordingForActor(body)
return NextResponse.json<RecordingRetryResponse>({ status: 'success', recording })
},
)

View file

@ -0,0 +1,122 @@
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import {
RECORDING_FILE_TYPE_MESSAGE,
RECORDING_TITLE_REQUIRED_MESSAGE,
recordingFileTypeAllowed,
recordingUploadTooLargeMessage,
toRecordingViewModel,
} from '@/lib/recording'
import {
RECORDING_FILE_NAME_MESSAGE,
RECORDING_FORBIDDEN_MESSAGE,
RECORDING_GENERIC_ERROR_MESSAGE,
recordingUploadMetadataSchema,
} from '@/lib/schemas/recording'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
import { campaignJsonMutationErrorResponse } from '@/utilities/campaignJsonMutationRoute'
import {
receiveRecordingUpload,
RECORDING_BODY_MISSING_MESSAGE,
} from '@/utilities/recordings/recordingUpload'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import type { RecordingUploadResponse } from '../types'
export const dynamic = 'force-dynamic'
/**
* C199 — accepts one recording upload as a RAW body: the request body IS the
* video, the metadata travels in the query string. A route handler instead of a
* server action because a multi-GB file must stream to disk (the action default
* of 1 MB and the FormData buffer of hours of video are both unacceptable); XHR
* still gives the dialog a real `upload.onprogress`.
*
* 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, like the multipart `ai-transcribe` route.
*/
const errorResponse = (message: string, status: number): NextResponse<RecordingUploadResponse> =>
NextResponse.json({ status: 'error', message }, { status })
const SAFE_MESSAGES = [
RECORDING_FORBIDDEN_MESSAGE,
RECORDING_TITLE_REQUIRED_MESSAGE,
RECORDING_FILE_NAME_MESSAGE,
RECORDING_FILE_TYPE_MESSAGE,
recordingUploadTooLargeMessage,
RECORDING_BODY_MISSING_MESSAGE,
]
export const POST = async (request: Request): Promise<NextResponse<RecordingUploadResponse>> => {
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(RECORDING_FORBIDDEN_MESSAGE, 403)
}
const searchParams = new URL(request.url).searchParams
const parsed = recordingUploadMetadataSchema.safeParse({
title: searchParams.get('title') ?? '',
recordedAt: searchParams.get('recordedAt') ?? undefined,
filename: searchParams.get('filename') ?? '',
})
if (!parsed.success) {
return errorResponse(parsed.error.issues[0]?.message ?? RECORDING_GENERIC_ERROR_MESSAGE, 400)
}
if (!recordingFileTypeAllowed(request.headers.get('content-type'))) {
return errorResponse(RECORDING_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 receiveRecordingUpload({
payload,
actor: user,
metadata: parsed.data,
body: request.body,
contentLength,
})
const recording = await payload.findByID({
collection: 'recording',
id,
depth: 0,
select: {
title: true,
status: true,
step: true,
recordedAt: true,
durationSeconds: true,
},
user,
overrideAccess: false,
})
return NextResponse.json<RecordingUploadResponse>({
status: 'success',
recording: toRecordingViewModel(recording),
})
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: SAFE_MESSAGES,
genericMessage: RECORDING_GENERIC_ERROR_MESSAGE,
})
}
}

View file

@ -0,0 +1,13 @@
import { redirect } from 'next/navigation'
import { buildAcervoSourceHref } from '@/utilities/recordings/recordingListUrl'
/**
* C199 — canonical alias of the recordings source. The list lives on
* `/campanha/comunicacao/acervo?source=enviadas` (the switcher keeps both
* sources on the same page); this segment exists so a hand-typed URL lands on
* the source instead of being swallowed by the `[id]` detail route.
*/
export default function RecordingsAliasPage() {
redirect(buildAcervoSourceHref('enviadas'))
}

View file

@ -0,0 +1,26 @@
import { NextResponse } from 'next/server'
import { getRecordingStatusesForActor } from '@/app/(campaign)/campanha/actions/recording'
import {
RECORDING_FORBIDDEN_MESSAGE,
RECORDING_GENERIC_ERROR_MESSAGE,
recordingStatusRequestSchema,
} from '@/lib/schemas/recording'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { RecordingStatusResponse } from '../types'
export const dynamic = 'force-dynamic'
/** C199 — polls the statuses of the recordings visible in the list. */
export const POST = campaignJsonMutationRoute(
{
bodySchema: recordingStatusRequestSchema,
safeMessages: [RECORDING_FORBIDDEN_MESSAGE],
genericMessage: RECORDING_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const recordings = await getRecordingStatusesForActor(body)
return NextResponse.json<RecordingStatusResponse>({ status: 'success', recordings })
},
)

View file

@ -0,0 +1,21 @@
import type { RecordingViewModel } from '@/lib/recording'
/** Wire contract of `POST .../gravacoes/enviar` (C199) — raw-body upload. */
export type RecordingUploadResponse =
| { status: 'success'; recording: RecordingViewModel }
| { status: 'error'; message: string }
/** Wire contract of `POST .../gravacoes/status` (C199) — status poll. */
export type RecordingStatusResponse =
| { status: 'success'; recordings: RecordingViewModel[] }
| { status: 'error'; message: string }
/** Wire contract of `DELETE .../gravacoes/[id]/apagar` (C199) — hard delete. */
export type RecordingDeleteResponse =
| { status: 'success'; deleted: true }
| { status: 'error'; message: string }
/** Wire contract of `POST .../gravacoes/[id]/retry` (C199) — retry failed ASR. */
export type RecordingRetryResponse =
| { status: 'success'; recording: RecordingViewModel }
| { status: 'error'; message: string }

View file

@ -1,15 +1,20 @@
import config from '@payload-config'
import { ScissorsIcon, SearchXIcon } from 'lucide-react'
import { ScissorsIcon, SearchIcon, SearchXIcon, VideoIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { AcervoSourceToggle } from '@/components/campaign/recording/AcervoSourceToggle'
import { RecordingResultList } from '@/components/campaign/recording/RecordingResultList'
import { RecordingStatusRefresher } from '@/components/campaign/recording/RecordingStatusRefresher'
import { RecordingUploadDialog } from '@/components/campaign/recording/RecordingUploadDialog'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { SpeechAcervoFilters } from '@/components/campaign/speech/SpeechAcervoFilters'
import { SpeechRefineSearchButton } from '@/components/campaign/speech/SpeechRefineSearchButton'
@ -20,6 +25,12 @@ import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { CAMPAIGN_COMMUNICATION_ACERVO, CAMPAIGN_COMMUNICATION_CORTES } from '@/lib/campaignPaths'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
buildAcervoSourceHref,
buildRecordingListHref,
parseAcervoSource,
} from '@/utilities/recordings/recordingListUrl'
import { loadRecordingsPageData } from '@/utilities/recordings/recordingPageData'
import { buildSpeechFiltersKey, buildSpeechListHref } from '@/utilities/speech/speechListUrl'
import { loadSpeechAcervoPageData } from '@/utilities/speech/speechPageData'
@ -29,13 +40,139 @@ type SpeechAcervoPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
/**
* C199 — the acervo header and its source switcher live above both sources:
* "Falas da Câmara" (the C154 catalog with its facets) and "Gravações enviadas"
* (the C199 recordings with their own search).
*/
const AcervoHeader = ({ source }: { source: 'camara' | 'enviadas' }) => (
<div className="flex flex-col gap-4 pt-4 md:pt-0">
{/* Mobile keeps only the switcher row: the shell chrome already carries the
title and the action joins the toggle (approved scene 6). */}
<div className="flex flex-wrap items-end justify-between gap-4 max-md:hidden">
<div>
<h1 className="text-xl font-semibold tracking-tight">Acervo de falas</h1>
<p className="mt-1 text-sm text-muted-foreground">
Busque nas falas da Câmara ou nas gravações da equipe.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" className="min-h-11">
<Link href={CAMPAIGN_COMMUNICATION_CORTES}>
<ScissorsIcon data-icon="inline-start" aria-hidden="true" />
Biblioteca de cortes
</Link>
</Button>
<RecordingUploadDialog />
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<AcervoSourceToggle source={source} />
<RecordingUploadDialog
triggerLabel=""
triggerAriaLabel="Enviar gravação"
triggerClassName="min-h-10 md:hidden"
/>
</div>
</div>
)
const RecordingsSearchForm = ({ query }: { query?: string }) => (
<form method="get" action={CAMPAIGN_COMMUNICATION_ACERVO} className="flex flex-col gap-1.5">
<input type="hidden" name="source" value="enviadas" />
{/* The shared input owns the accessible label; this is its visible twin. */}
<p className="text-sm font-medium">Buscar nas gravações enviadas</p>
<CampaignSearchInput
id="recording-search"
name="q"
label="Buscar nas gravações enviadas"
defaultValue={query}
placeholder="Busque por palavra ou trecho da transcrição…"
/>
<button type="submit" className="sr-only">
Buscar
</button>
</form>
)
const RecordingsSource = ({
data,
}: {
data: Awaited<ReturnType<typeof loadRecordingsPageData>>
}) => {
const hasSearch = Boolean(data.state.q)
return (
<CampaignListPendingBoundary>
<RecordingsSearchForm query={data.state.q} />
<RecordingStatusRefresher
recordings={data.rows.map((row) => ({ id: row.id, status: row.status }))}
/>
<CampaignListResults>
{data.rows.length > 0 ? (
<RecordingResultList rows={data.rows} />
) : (
<CampaignListEmptyState
className="border-solid"
icon={hasSearch ? SearchIcon : VideoIcon}
title={
hasSearch
? `Nenhuma gravação encontrada para "${data.state.q}"`
: 'Nenhuma gravação enviada ainda'
}
description={
hasSearch
? 'Tente outro termo ou limpe a busca para ver todas as gravações enviadas.'
: 'Envie um arquivo de vídeo para acompanhar a transcrição e pesquisar o conteúdo no acervo.'
}
>
{hasSearch ? (
<Button asChild variant="outline" className="min-h-11">
<Link href={buildAcervoSourceHref('enviadas')}>Limpar busca</Link>
</Button>
) : (
<RecordingUploadDialog triggerLabel="Enviar gravação" />
)}
</CampaignListEmptyState>
)}
{data.rows.length > 0 ? (
<CampaignListFooter
totalDocs={data.totalDocs}
singular="gravação encontrada"
plural="gravações encontradas"
page={data.state.page}
totalPages={data.totalPages}
hrefForPage={(page) => buildRecordingListHref(data.state, page)}
/>
) : null}
</CampaignListResults>
</CampaignListPendingBoundary>
)
}
export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPageProps) {
const [user, payload] = await Promise.all([
const [user, payload, params] = await Promise.all([
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
searchParams,
])
const data = await loadSpeechAcervoPageData(payload, user, searchParams)
if (parseAcervoSource(params) === 'enviadas') {
const recordings = await loadRecordingsPageData(payload, user, params)
if (recordings.redirectHref) redirect(recordings.redirectHref)
return (
<CampaignPageShell aria-label="Acervo de gravações">
<AcervoHeader source="enviadas" />
<RecordingsSource data={recordings} />
</CampaignPageShell>
)
}
const data = await loadSpeechAcervoPageData(payload, user, params)
if (data.redirectHref) redirect(data.redirectHref)
const hasFilters = buildSpeechFiltersKey(data.state) !== ''
@ -44,14 +181,7 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag
return (
<CampaignPageShell aria-label="Acervo de falas">
<div className="flex justify-end pt-4 md:pt-0">
<Button asChild variant="outline" className="min-h-11">
<Link href={CAMPAIGN_COMMUNICATION_CORTES}>
<ScissorsIcon data-icon="inline-start" aria-hidden="true" />
Biblioteca de cortes
</Link>
</Button>
</div>
<AcervoHeader source="camara" />
<CampaignListPendingBoundary>
<SpeechAcervoFilters

View file

@ -3,15 +3,15 @@ import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
import {
canServeReelMedia,
isReelMediaKind,
REEL_MEDIA_SLUG,
reelMediaFieldByKind,
} from '@/lib/reel'
import { REEL_MEDIA_CACHE_CONTROL } from '@/lib/reelMedia'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { buildReelMediaResponse } from '@/utilities/reels/reelMediaResponse'
import { buildPrivateMediaResponse } from '@/utilities/privateMedia/privateMediaResponse'
export const dynamic = 'force-dynamic'
@ -25,7 +25,10 @@ export const dynamic = 'force-dynamic'
*/
const notFound = (): NextResponse =>
new NextResponse(null, { status: 404, headers: { 'Cache-Control': REEL_MEDIA_CACHE_CONTROL } })
new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL },
})
export const GET = async (
request: Request,
@ -57,7 +60,7 @@ export const GET = async (
const staticDir =
upload && typeof upload === 'object' && upload.staticDir ? upload.staticDir : REEL_MEDIA_SLUG
return buildReelMediaResponse({
return buildPrivateMediaResponse({
media,
staticDir,
rangeHeader: request.headers.get('range'),

View file

@ -0,0 +1,185 @@
'use server'
import type { Payload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import {
RECORDING_MEDIA_SLUG,
toRecordingViewModel,
type RecordingViewModel,
} from '@/lib/recording'
import {
RECORDING_FORBIDDEN_MESSAGE,
RECORDING_NOT_FOUND_MESSAGE,
RECORDING_RETRY_NOT_FAILED_MESSAGE,
recordingDeleteRequestSchema,
recordingRetryRequestSchema,
recordingStatusRequestSchema,
type RecordingStatusRequest,
} from '@/lib/schemas/recording'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { onPayloadTransactionCommit, withPayloadTransaction } from '@/utilities/payloadTransaction'
import { reapStaleRecording } from '@/utilities/recordings/recordingJob'
import { startRecordingJobInBackground } from '@/utilities/recordings/recordingScheduler'
/**
* C199 — mutations of one uploaded recording: retry the failed transcription,
* poll the visible statuses and hard-delete the row with its transcript and
* private media. The acervo gate is repeated fresh before any read or write
* (same contract as the cut actions); the collection access is the final
* barrier.
*/
const recordingSelect = {
title: true,
status: true,
step: true,
recordedAt: true,
durationSeconds: true,
media: true,
updatedAt: true,
} as const
const loadRecordingForActor = async (
payload: Payload,
actor: CampaignUser,
recordingId: number,
) => {
const result = await payload.find({
collection: 'recording',
where: { id: { equals: recordingId } },
depth: 0,
limit: 1,
pagination: false,
select: recordingSelect,
user: actor,
overrideAccess: false,
})
return result.docs[0]
}
/** Retries the transcription of a failed recording (same row, same job). */
export const retryRecordingForActor = async (input: {
recordingId: number
}): Promise<RecordingViewModel> => {
const parsed = recordingRetryRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(RECORDING_FORBIDDEN_MESSAGE)
const current = await loadRecordingForActor(payload, actor, parsed.recordingId)
if (!current) throw new Error(RECORDING_NOT_FOUND_MESSAGE)
if (current.status !== 'failed') throw new Error(RECORDING_RETRY_NOT_FAILED_MESSAGE)
// Conditional update: two concurrent retries cannot both schedule a job.
const updated = await payload.update({
collection: 'recording',
where: {
and: [{ id: { equals: parsed.recordingId } }, { status: { equals: 'failed' } }],
},
data: { status: 'processing', step: 'extracting', error: null },
depth: 0,
select: recordingSelect,
user: actor,
overrideAccess: false,
})
const recording = updated.docs[0]
if (!recording) throw new Error(RECORDING_RETRY_NOT_FAILED_MESSAGE)
startRecordingJobInBackground(recording.id)
return toRecordingViewModel(recording)
}
/**
* Polls the statuses of the visible recordings; a stale `processing` row is
* reaped to `failed` on the way (there is no queue to ask).
*/
export const getRecordingStatusesForActor = async (
input: RecordingStatusRequest,
): Promise<RecordingViewModel[]> => {
const parsed = recordingStatusRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(RECORDING_FORBIDDEN_MESSAGE)
const where = { id: { in: parsed.recordingIds } }
const first = await payload.find({
collection: 'recording',
where,
depth: 0,
limit: parsed.recordingIds.length,
pagination: false,
select: recordingSelect,
user: actor,
overrideAccess: false,
})
await Promise.all(
first.docs.map((recording) =>
reapStaleRecording(payload, {
id: recording.id,
status: recording.status,
updatedAt: recording.updatedAt,
}),
),
)
const result = await payload.find({
collection: 'recording',
where,
depth: 0,
limit: parsed.recordingIds.length,
pagination: false,
select: recordingSelect,
user: actor,
overrideAccess: false,
})
return result.docs.map(toRecordingViewModel)
}
/**
* Hard-deletes one recording: the row and its transcript go in one transaction
* (the collection's `beforeDelete` cascade owns the segments); the private media
* row is removed after commit, best-effort, so a storage hiccup cannot fail the
* delete the person asked for.
*/
export const deleteRecordingForActor = async (input: {
recordingId: number
}): Promise<{ deleted: true }> => {
const parsed = recordingDeleteRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(RECORDING_FORBIDDEN_MESSAGE)
const current = await loadRecordingForActor(payload, actor, parsed.recordingId)
if (!current) throw new Error(RECORDING_NOT_FOUND_MESSAGE)
const mediaId = typeof current.media === 'number' ? current.media : (current.media?.id ?? null)
await withPayloadTransaction(payload, async ({ transactionID, req }) => {
await payload.delete({
collection: 'recording',
id: parsed.recordingId,
user: actor,
overrideAccess: false,
req,
})
if (mediaId !== null) {
onPayloadTransactionCommit(transactionID, () => {
void payload
.delete({
collection: RECORDING_MEDIA_SLUG,
id: mediaId,
// Intentional admin bypass: cleanup of the file that belonged to
// the recording this actor was authorized to delete.
overrideAccess: true,
})
.catch(() => undefined)
})
}
})
return { deleted: true }
}

View file

@ -0,0 +1,147 @@
import type { CollectionBeforeDeleteHook, CollectionConfig } from 'payload'
import {
RECORDING_MEDIA_SLUG,
RECORDING_STATUSES,
RECORDING_STEPS,
RECORDING_TITLE_MAX_LENGTH,
recordingStatusLabels,
recordingStepLabels,
} from '@/lib/recording'
import {
canDeleteRecording,
canReadRecording,
canSetCampaignSystemField,
canUploadRecording,
} from '@/utilities/campaignAccess'
import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields'
/**
* C199 — one recording uploaded by the communication team (plenária, debate,
* material próprio) and turned into a searchable acervo source with player and
* timestamped transcript. `status`/`step`/`error` are the honest processing
* states of the transcription job; `media` is the private file, served only
* through the authenticated route under `/campanha`.
*/
const STATUS_OPTIONS = RECORDING_STATUSES.map((value) => ({
value,
label: recordingStatusLabels[value],
}))
const STEP_OPTIONS = RECORDING_STEPS.map((value) => ({
value,
label: recordingStepLabels[value],
}))
/** Segments are owned by the recording; Payload relationships do not cascade. */
const deleteRecordingSegments: CollectionBeforeDeleteHook = async ({ id, req }) => {
await req.payload.delete({
collection: 'recordingSegment',
where: { recording: { equals: id } },
req,
// Intentional bypass: the cascade is owned by this hook; segment access is
// admin-only and this runs inside the authorized recording delete request.
overrideAccess: true,
})
}
export const Recording: CollectionConfig = {
slug: 'recording',
labels: {
singular: 'Gravação',
plural: 'Gravações',
},
admin: {
group: 'Comunicação',
useAsTitle: 'title',
defaultColumns: ['title', 'recordedAt', 'status', 'durationSeconds', 'createdAt'],
description:
'Gravações próprias da equipe no acervo. O arquivo é privado; a transcrição é somente leitura.',
},
access: {
create: canUploadRecording,
read: canReadRecording,
update: canUploadRecording,
delete: canDeleteRecording,
},
hooks: {
beforeChange: [stampCampaignCreatedBy],
beforeDelete: [deleteRecordingSegments],
},
fields: [
{
name: 'title',
type: 'text',
label: 'Título',
required: true,
maxLength: RECORDING_TITLE_MAX_LENGTH,
},
{
name: 'recordedAt',
type: 'date',
label: 'Data da gravação',
index: true,
admin: {
description: 'Data em que a gravação foi feita (opcional).',
},
},
{
name: 'status',
type: 'select',
label: 'Estado',
required: true,
defaultValue: 'uploading',
index: true,
options: STATUS_OPTIONS,
},
{
name: 'step',
type: 'select',
label: 'Passo',
options: STEP_OPTIONS,
admin: {
readOnly: true,
description: 'Progresso honesto da transcrição em andamento.',
},
},
{
name: 'media',
type: 'upload',
relationTo: RECORDING_MEDIA_SLUG,
label: 'Arquivo da gravação',
admin: {
readOnly: true,
description: 'O arquivo privado que o player toca e o download entrega.',
},
},
{
name: 'durationSeconds',
type: 'number',
label: 'Duração (s)',
admin: {
readOnly: true,
description: 'Derivada do áudio transcrito.',
},
},
{
name: 'searchText',
type: 'textarea',
label: 'Texto normalizado (busca)',
admin: {
readOnly: true,
description: 'Concatenação normalizada dos segmentos (sem acentos, minúsculas).',
},
},
{
name: 'error',
type: 'textarea',
label: 'Erro',
admin: {
readOnly: true,
description: 'Motivo interno da falha; nunca vai à pessoa com o detalhe cru.',
},
},
systemStampedActorField({ setAccess: canSetCampaignSystemField }),
],
}

View file

@ -0,0 +1,48 @@
import type { CollectionConfig } from 'payload'
import { RECORDING_MEDIA_SLUG } from '@/lib/recording'
import {
canDeleteRecording,
canReadRecording,
canUploadRecording,
} from '@/utilities/campaignAccess'
/**
* C199 — private upload collection of an uploaded recording's media. Like
* `reelMedia`, deliberately NOT `media`: that collection is anonymous-read by
* contract (public pages), while a recording file may only be read by the
* communication roles. The browser reaches the file through the authenticated
* `/campanha/comunicacao/acervo/gravacoes/[id]/arquivo` route, never the public
* `/api/media/file` proxy.
*/
export const RecordingMedia: CollectionConfig = {
slug: RECORDING_MEDIA_SLUG,
labels: {
singular: 'Mídia de gravação',
plural: 'Mídias de gravação',
},
admin: {
group: 'Comunicação',
description:
'Arquivos privados das gravações enviadas ao acervo. Só abrem com login da campanha.',
},
access: {
create: canUploadRecording,
read: canReadRecording,
update: canUploadRecording,
delete: canDeleteRecording,
},
fields: [
{
name: 'alt',
type: 'text',
label: 'Texto alternativo',
required: true,
admin: {
description: 'Descrição do arquivo para acessibilidade.',
},
},
],
upload: true,
}

View file

@ -0,0 +1,84 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { normalizeForSearch } from '@/lib/speechSearch'
import { canReadRecording, payloadAdminOnly } from '@/utilities/campaignAccess'
/**
* C199 — searchable ASR segment of an uploaded recording: one timestamped block
* of the transcript with the trigram-search index (`recording_segment_search_text_trgm_idx`,
* hand-written migration). Admin-hidden: the product surface is the recording
* detail with its clickable transcript.
*/
const deriveSearchText: CollectionBeforeValidateHook = ({ data, originalDoc }) => {
if (!data) return data
const text = typeof data.text === 'string' ? data.text : originalDoc?.text
data.searchText = normalizeForSearch(text ?? '')
return data
}
export const RecordingSegment: CollectionConfig = {
slug: 'recordingSegment',
labels: {
singular: 'Segmento de gravação',
plural: 'Segmentos de gravação',
},
admin: {
group: 'Comunicação',
hidden: () => true,
useAsTitle: 'text',
},
access: {
create: payloadAdminOnly,
read: canReadRecording,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
hooks: {
beforeValidate: [deriveSearchText],
},
fields: [
{
name: 'recording',
type: 'relationship',
relationTo: 'recording',
label: 'Gravação',
required: true,
index: true,
},
{
name: 'order',
type: 'number',
label: 'Ordem',
required: true,
},
{
name: 'startSeconds',
type: 'number',
label: 'Início (s)',
required: true,
},
{
name: 'endSeconds',
type: 'number',
label: 'Fim (s)',
required: true,
},
{
name: 'text',
type: 'textarea',
label: 'Texto',
required: true,
},
{
name: 'searchText',
type: 'text',
label: 'Texto normalizado (busca)',
required: true,
admin: {
readOnly: true,
description: 'Derivado do texto (sem acentos, minúsculas) para a busca por palavra.',
},
},
],
}

View file

@ -0,0 +1,40 @@
import Link from 'next/link'
import { cn } from '@/lib/utils'
import { buildAcervoSourceHref, type AcervoSource } from '@/utilities/recordings/recordingListUrl'
const SOURCE_TABS = [
{ source: 'camara', label: 'Falas da Câmara' },
{ source: 'enviadas', label: 'Gravações enviadas' },
] as const satisfies readonly { source: AcervoSource; label: string }[]
/**
* C199 — the acervo source switcher. Navigation, not a tab widget: two links
* with `aria-current="page"` on the active one, keeping the pill appearance of
* the approved design.
*/
export const AcervoSourceToggle = ({ source }: { source: AcervoSource }) => (
<nav
aria-label="Fonte do acervo"
className="inline-flex self-start rounded-lg bg-secondary p-1 text-sm"
>
{SOURCE_TABS.map((tab) => {
const active = tab.source === source
return (
<Link
key={tab.source}
href={buildAcervoSourceHref(tab.source)}
aria-current={active ? 'page' : undefined}
className={cn(
'rounded-md px-3 py-2',
active
? 'bg-card font-medium text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground',
)}
>
{tab.label}
</Link>
)
})}
</nav>
)

View file

@ -0,0 +1,117 @@
'use client'
import { CircleAlertIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import type { RecordingDeleteResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/types'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/AlertDialog'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { deleteCampaignJson } from '@/lib/campaignJsonRequest'
import { campaignRecordingDeleteHref } from '@/lib/campaignPaths'
import { cn } from '@/lib/utils'
const DELETE_ERROR_MESSAGE = 'Não foi possível apagar a gravação.'
/**
* C199 — the delete confirmation of the recording detail. The trigger lives
* only here (never on the list cards): an irreversible action stays out of the
* scan path, per the approved design.
*/
export const RecordingDeleteDialog = ({
recordingId,
redirectTo,
triggerClassName,
}: {
recordingId: number
/** Detail page: the row is gone after the delete, so leave the page. */
redirectTo?: string
triggerClassName?: string
}) => {
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const confirmDelete = async () => {
setSubmitting(true)
setError(null)
try {
const { ok, payload } = await deleteCampaignJson<RecordingDeleteResponse>(
campaignRecordingDeleteHref(recordingId),
)
if (!ok || payload.status !== 'success') {
setError(payload.status === 'error' ? payload.message : DELETE_ERROR_MESSAGE)
return
}
if (redirectTo) router.push(redirectTo)
else router.refresh()
} catch {
setError(DELETE_ERROR_MESSAGE)
} finally {
setSubmitting(false)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
className={cn('min-h-11 text-destructive hover:bg-red-50', triggerClassName)}
>
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
Apagar
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<div className="flex items-start gap-3">
<div className="grid size-9 shrink-0 place-items-center rounded-full bg-red-50 text-destructive">
<CircleAlertIcon className="size-5" aria-hidden="true" />
</div>
<div className="min-w-0">
{/* The frontend prose base styles every `h2` with a bottom border. */}
<AlertDialogTitle className="border-b-0 pb-0">Apagar esta gravação?</AlertDialogTitle>
<AlertDialogDescription className="mt-2">
O arquivo e toda a transcrição serão removidos. Esta ação não pode ser desfeita.
</AlertDialogDescription>
</div>
</div>
</AlertDialogHeader>
<AlertDialogFooter className="border-t-0 bg-transparent">
<AlertDialogCancel type="button" className="min-h-11" disabled={submitting}>
Cancelar
</AlertDialogCancel>
<Button
type="button"
variant="destructive"
className="min-h-11 bg-destructive text-white hover:bg-destructive/90"
disabled={submitting}
onClick={() => void confirmDelete()}
>
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Apagar gravação
</Button>
</AlertDialogFooter>
{error ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -0,0 +1,225 @@
'use client'
import { CircleAlertIcon, DownloadIcon, PlayIcon, UploadIcon } from 'lucide-react'
import { useRef, useState } from 'react'
import { RecordingRetryButton } from '@/components/campaign/recording/RecordingRetryButton'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
import { Button } from '@/components/ui/button'
import { canRetryRecording, type RecordingStatus } from '@/lib/recording'
import { cn } from '@/lib/utils'
import type { RecordingDetailSegmentViewModel } from '@/utilities/recordings/recordingViewModels'
type RecordingDetailPlayerProps = {
recordingId: number
status: RecordingStatus
fileHref: string
downloadHref: string
failureMessage: string | null
segments: readonly RecordingDetailSegmentViewModel[]
/** `?t=` of the search hit; null when the detail was opened without a term. */
initialSeconds: number | null
}
/**
* C199 — the recording detail media surface: the private player, the download
* and the clickable transcript (left/right columns of the approved design). In
* `processing`/`failed` the file already exists (the upload finished), so
* player and download stay available and only the transcript is replaced by the
* honest state panel.
*/
export const RecordingDetailPlayer = ({
recordingId,
status,
fileHref,
downloadHref,
failureMessage,
segments,
initialSeconds,
}: RecordingDetailPlayerProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
// The first segment starts highlighted: the design's transcript shows the
// read position without waiting for the first `timeupdate`.
const [activeStart, setActiveStart] = useState<number | null>(
initialSeconds ?? segments[0]?.startSeconds ?? null,
)
// Accepting the retry swaps the failure panel for the processing one before
// the server round trip lands (approved scene 9).
const [retrying, setRetrying] = useState(false)
const hasFile = status !== 'uploading'
const showTranscript = status === 'ready' && segments.length > 0
const seekTo = (seconds: number) => {
const video = videoRef.current
if (!video) return
video.currentTime = seconds
setActiveStart(seconds)
void video.play().catch(() => undefined)
}
const onTimeUpdate = () => {
const video = videoRef.current
if (!video) return
const current = video.currentTime
const active = segments.find(
(segment) => current >= segment.startSeconds && current < segment.endSeconds,
)
setActiveStart(active?.startSeconds ?? null)
}
const mediaColumn = (
<div className="min-w-0">
{hasFile ? (
<video
ref={videoRef}
controls
preload="metadata"
src={fileHref}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={() => {
const video = videoRef.current
if (video && initialSeconds !== null && initialSeconds > 0) {
video.currentTime = initialSeconds
setActiveStart(initialSeconds)
}
}}
className="aspect-video w-full rounded-lg border border-black/30 bg-black"
>
<track kind="captions" />
</video>
) : (
<div
role="status"
aria-live="polite"
className="flex aspect-video w-full flex-col items-center justify-center rounded-lg border border-dashed border-input bg-stone-50 px-6 text-center"
>
<UploadIcon className="size-7 text-muted-foreground" aria-hidden="true" />
<p className="mt-3 text-sm font-medium">O arquivo ainda está sendo enviado</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Player e download aparecem quando o envio terminar.
</p>
</div>
)}
{status === 'processing' || retrying ? (
<div className="mt-4 rounded-lg bg-amber-50 p-3 text-xs leading-5 text-amber-900">
<div className="flex items-center gap-2 font-semibold">
<span
className="size-4 animate-spin rounded-full border-2 border-amber-300 border-t-amber-800 motion-reduce:animate-none"
aria-hidden="true"
/>
{retrying ? 'Reprocessando a transcrição' : 'Transcrição em processamento'}
</div>
<p className="mt-1">
{retrying
? 'O arquivo foi preservado. Você pode assistir enquanto a nova transcrição é preparada.'
: 'O vídeo já pode ser assistido. A transcrição ainda não está disponível.'}
</p>
</div>
) : null}
{canRetryRecording(status) && !retrying ? (
<div className="mt-4 rounded-lg border border-destructive/30 bg-red-50/60 p-3 text-xs leading-5 text-red-800">
<div className="flex items-center gap-2 font-semibold">
<CircleAlertIcon className="size-4" aria-hidden="true" />A transcrição não foi concluída
</div>
<p className="mt-1">{failureMessage ?? 'Não foi possível processar o áudio.'}</p>
<p className="mt-1">O arquivo foi preservado.</p>
</div>
) : null}
{hasFile ? (
<div className="mt-3 flex flex-wrap items-start gap-2 max-md:flex-col max-md:items-stretch">
{canRetryRecording(status) ? (
<RecordingRetryButton
recordingId={recordingId}
className="max-md:w-full"
onSubmittingChange={setRetrying}
/>
) : null}
<Button
asChild
variant={status === 'ready' ? 'default' : 'outline'}
className="min-h-11 max-md:w-full"
>
<a href={downloadHref}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
{status === 'ready' ? 'Baixar' : 'Baixar arquivo'}
</a>
</Button>
</div>
) : null}
{status === 'uploading' ? (
<p className="mt-4 flex items-center gap-2 text-xs text-muted-foreground">
<PlayIcon className="size-4" aria-hidden="true" />A transcrição começa quando o envio
terminar.
</p>
) : null}
{status === 'ready' && segments.length === 0 ? (
<p className="mt-4 text-xs text-muted-foreground">
A transcrição desta gravação está vazia — o arquivo continua disponível para download.
</p>
) : null}
</div>
)
if (!showTranscript) {
// Non-ready states keep the media column bounded on desktop so the state
// panel and the recovery action stay in the first fold (approved scene 8).
return (
<div data-slot="recording-player">
<div className="lg:max-w-3xl">{mediaColumn}</div>
</div>
)
}
return (
<div
data-slot="recording-player"
className="grid gap-6 lg:grid-cols-[minmax(0,1.35fr)_minmax(340px,.65fr)]"
>
{mediaColumn}
<section aria-label="Transcrição" className="min-w-0">
<div className="flex items-center justify-between">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Transcrição
</p>
<span className="text-[11px] text-muted-foreground">Clique para posicionar</span>
</div>
<ol className="mt-2 max-h-[28rem] space-y-0.5 overflow-y-auto">
{segments.map((segment) => {
const active = activeStart === segment.startSeconds
return (
<li key={`${segment.startSeconds}-${segment.startLabel}`}>
<button
type="button"
data-start-seconds={segment.startSeconds}
onClick={() => seekTo(segment.startSeconds)}
className={cn(
'grid w-full grid-cols-[3.25rem_1fr] gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-muted',
)}
>
<span
className={cn(
'pt-0.5 text-xs tabular-nums',
active ? 'text-primary' : 'text-muted-foreground',
)}
>
{segment.startLabel}
</span>
<span className="text-sm leading-relaxed text-foreground/90">
<SpeechHighlightParts parts={segment.parts} />
</span>
</button>
</li>
)
})}
</ol>
</section>
</div>
)
}

View file

@ -0,0 +1,82 @@
import { ChevronRightIcon, VideoIcon } from 'lucide-react'
import Link from 'next/link'
import { RecordingStatusBadge } from '@/components/campaign/recording/RecordingStatusBadge'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
import { Button } from '@/components/ui/button'
import type { RecordingStatus } from '@/lib/recording'
import type { RecordingListItemViewModel } from '@/utilities/recordings/recordingViewModels'
const STATE_COPY: Record<Exclude<RecordingStatus, 'uploading'>, string> = {
processing: 'Transcrevendo a gravação. Ela ainda não aparece na busca.',
failed: 'A transcrição não foi concluída. O arquivo foi preservado — reprocesse no detalhe.',
ready: 'Transcrição pronta e pesquisável.',
}
const metaLine = (recording: RecordingListItemViewModel): string => {
const parts = [recording.recordedAtLabel ?? 'Sem data']
if (recording.durationLabel) parts.push(recording.durationLabel)
return parts.join(' · ')
}
/**
* C199 — one recording in the "Gravações enviadas" list: state chip, date,
* duration, the matching excerpt when the search found one and the single
* "Abrir" action (delete lives only in the detail — design decision).
*/
export const RecordingResultCard = ({ recording }: { recording: RecordingListItemViewModel }) => {
const excerpt = recording.excerpt
return (
<article className="grid gap-4 rounded-xl border bg-card p-4 md:grid-cols-[112px_minmax(0,1fr)_auto]">
<Link
href={recording.watchHref}
aria-hidden="true"
tabIndex={-1}
className="grid aspect-video place-items-center rounded-lg bg-stone-200 text-stone-500 md:w-28"
>
<VideoIcon className="size-7" aria-hidden="true" />
</Link>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold">{recording.title}</h3>
<RecordingStatusBadge status={recording.status} />
</div>
<p className="mt-1 text-xs text-muted-foreground">{metaLine(recording)}</p>
{recording.status === 'uploading' ? (
<div className="mt-2">
<div className="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-1.5 text-xs text-muted-foreground">
Arquivo sendo enviado · ainda não pesquisável
</p>
</div>
) : excerpt ? (
<p className="mt-2 text-sm leading-5 text-foreground/90">
{excerpt.truncatedStart ? '… ' : null}
<SpeechHighlightParts parts={excerpt.parts} />
{excerpt.truncatedEnd ? ' …' : null}
</p>
) : (
<p className="mt-2 text-sm leading-5 text-muted-foreground">
{STATE_COPY[recording.status]}
</p>
)}
</div>
<Button
asChild
variant="outline"
className="min-h-11 self-center max-md:w-full md:self-center"
>
<Link href={recording.watchHref}>
Abrir
<ChevronRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</Button>
</article>
)
}

View file

@ -0,0 +1,10 @@
import { RecordingResultCard } from '@/components/campaign/recording/RecordingResultCard'
import type { RecordingListItemViewModel } from '@/utilities/recordings/recordingViewModels'
export const RecordingResultList = ({ rows }: { rows: readonly RecordingListItemViewModel[] }) => (
<div className="flex flex-col gap-3">
{rows.map((row) => (
<RecordingResultCard key={row.id} recording={row} />
))}
</div>
)

View file

@ -0,0 +1,86 @@
'use client'
import { CircleAlertIcon, RotateCcwIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import type { RecordingRetryResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/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 { campaignRecordingRetryHref } from '@/lib/campaignPaths'
const RETRY_ERROR_MESSAGE = 'Não foi possível iniciar o reprocessamento. Tente novamente.'
/**
* C199 — reprocesses a failed transcription in place. The failure panel of the
* detail swaps to "Reprocessando" while the request is in flight (through
* `onSubmittingChange`) and the error of the click stays as a bordered alert
* right under the action, never a toast (approved scene 9).
*/
export const RecordingRetryButton = ({
recordingId,
className,
onSubmittingChange,
}: {
recordingId: number
className?: string
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<RecordingRetryResponse>(
campaignRecordingRetryHref(recordingId),
{ recordingId },
)
if (!ok || payload.status !== 'success') {
// The action-level error keeps the approved copy (the failure panel
// above already carries the transcript's own reason).
setError(RETRY_ERROR_MESSAGE)
return
}
router.refresh()
} catch {
setError(RETRY_ERROR_MESSAGE)
} finally {
updateSubmitting(false)
}
}
return (
<div className={className}>
<Button
type="button"
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…' : 'Reprocessar transcrição'}
</Button>
{error ? (
<Alert variant="destructive" className="mt-2 py-2">
<CircleAlertIcon aria-hidden="true" />
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
</div>
)
}

View file

@ -0,0 +1,28 @@
import { recordingStatusLabels, type RecordingStatus } from '@/lib/recording'
import { cn } from '@/lib/utils'
const TONE_BY_STATUS: Record<RecordingStatus, string> = {
uploading: 'bg-muted text-muted-foreground',
processing: 'bg-amber-100 text-amber-900',
ready: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-700',
}
/** C199 — the one status chip of the recording surfaces (list and detail). */
export const RecordingStatusBadge = ({
status,
className,
}: {
status: RecordingStatus
className?: string
}) => (
<span
className={cn(
'inline-flex min-h-5 items-center rounded-full px-2 py-0.5 text-xs font-semibold',
TONE_BY_STATUS[status],
className,
)}
>
{recordingStatusLabels[status]}
</span>
)

View file

@ -0,0 +1,65 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import type { RecordingStatusResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/types'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { CAMPAIGN_RECORDING_STATUS_HREF } from '@/lib/campaignPaths'
/**
* C199 — keeps the honest processing states moving without a full reload: while
* a visible recording is `uploading`/`processing`, 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 RecordingStatusRefresher = ({
recordings,
intervalMs = 5_000,
}: {
recordings: readonly { id: number; status: string }[]
intervalMs?: number
}) => {
const router = useRouter()
const pending = recordings.some(
(recording) => recording.status === 'uploading' || recording.status === 'processing',
)
const signature = recordings
.map((recording) => `${recording.id}:${recording.status}`)
.sort()
.join(',')
useEffect(() => {
// Terminal states never poll: the timer only exists while something is
// actually moving.
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<RecordingStatusResponse>(
CAMPAIGN_RECORDING_STATUS_HREF,
{ recordingIds: ids },
)
if (stopped || !ok || payload.status !== 'success') return
const nextSignature = payload.recordings
.map((recording) => `${recording.id}:${recording.status}`)
.sort()
.join(',')
if (nextSignature !== signature) router.refresh()
} catch {
// A failed poll is a lost tick, never a user-facing error.
}
}
const timer = window.setInterval(() => void tick(), intervalMs)
return () => {
stopped = true
window.clearInterval(timer)
}
}, [signature, pending, intervalMs, router])
return null
}

View file

@ -0,0 +1,388 @@
'use client'
import { CircleAlertIcon, FileIcon, UploadIcon, VideoIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
import type { RecordingUploadResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/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 { CAMPAIGN_RECORDING_UPLOAD_HREF } from '@/lib/campaignPaths'
import {
formatRecordingFileSize,
RECORDING_FILE_REQUIRED_MESSAGE,
RECORDING_FILE_TYPE_MESSAGE,
RECORDING_MAX_BYTES,
RECORDING_MAX_SIZE_LABEL,
RECORDING_TITLE_MAX_LENGTH,
RECORDING_TITLE_REQUIRED_MESSAGE,
recordingFileTypeAllowed,
recordingTooLargeMessage,
} from '@/lib/recording'
import { cn } from '@/lib/utils'
const GENERIC_ERROR_MESSAGE = 'Não foi possível enviar a gravação. Tente novamente.'
type UploadPhase = 'idle' | 'uploading' | 'error'
type RecordingUploadDialogProps = {
/** Layout of the trigger ("Enviar gravação" in the header and the empty state). */
triggerClassName?: string
triggerVariant?: 'default' | 'outline'
triggerLabel?: string
/** Icon-only triggers (mobile header row) still need an accessible name. */
triggerAriaLabel?: string
}
/**
* C199 — the one upload surface of the recordings source. Sends the RAW file
* with XHR (real `upload.onprogress`, no server-action buffer) with the metadata
* in the query string; the dialog stays open and unclosable while the file is
* in flight, per the approved design.
*/
export const RecordingUploadDialog = ({
triggerClassName,
triggerVariant = 'default',
triggerLabel = 'Enviar gravação',
triggerAriaLabel,
}: RecordingUploadDialogProps) => {
const router = useRouter()
const fileInputRef = useRef<HTMLInputElement>(null)
const xhrRef = useRef<XMLHttpRequest | null>(null)
const [open, setOpen] = useState(false)
const [file, setFile] = useState<File | null>(null)
const [title, setTitle] = useState('')
const [recordedAt, setRecordedAt] = useState('')
const [phase, setPhase] = useState<UploadPhase>('idle')
const [progress, setProgress] = useState(0)
const [fileError, setFileError] = useState<string | null>(null)
const [titleError, setTitleError] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const uploading = phase === 'uploading'
// Navigating away unmounts the dialog: abort the in-flight XHR instead of
// leaving an invisible multi-GB upload running (the reaper cleans a row left
// in `uploading` by a crash).
useEffect(
() => () => {
xhrRef.current?.abort()
xhrRef.current = null
},
[],
)
const reset = () => {
setFile(null)
setTitle('')
setRecordedAt('')
setPhase('idle')
setProgress(0)
setFileError(null)
setTitleError(null)
setError(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const onFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const next = event.target.files?.[0] ?? null
setFileError(null)
if (!next) {
setFile(null)
return
}
// The refused file stays visible with its name/size; the error below the
// picker explains the refusal and blocks the submit (approved scene 10).
setFile(next)
if (!recordingFileTypeAllowed(next.type)) {
setFileError(RECORDING_FILE_TYPE_MESSAGE)
return
}
if (next.size > RECORDING_MAX_BYTES) {
setFileError(recordingTooLargeMessage(next.size))
}
}
const submit = () => {
const nextTitle = title.trim()
setTitleError(null)
setError(null)
if (!file) {
setFileError(RECORDING_FILE_REQUIRED_MESSAGE)
return
}
if (fileError) return
if (!nextTitle) {
setTitleError(RECORDING_TITLE_REQUIRED_MESSAGE)
return
}
const params = new URLSearchParams({ title: nextTitle, filename: file.name })
if (recordedAt) params.set('recordedAt', recordedAt)
const xhr = new XMLHttpRequest()
xhrRef.current = xhr
setPhase('uploading')
setProgress(0)
xhr.open('POST', `${CAMPAIGN_RECORDING_UPLOAD_HREF}?${params.toString()}`)
xhr.setRequestHeader('Accept', 'application/json')
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && event.total > 0) {
setProgress(Math.round((event.loaded / event.total) * 100))
}
})
xhr.addEventListener('load', () => {
xhrRef.current = null
try {
const payload = JSON.parse(xhr.responseText) as RecordingUploadResponse
if (xhr.status >= 200 && xhr.status < 300 && payload.status === 'success') {
reset()
setOpen(false)
router.refresh()
return
}
setPhase('error')
setError(payload.status === 'error' ? payload.message : GENERIC_ERROR_MESSAGE)
} catch {
setPhase('error')
setError(GENERIC_ERROR_MESSAGE)
}
})
xhr.addEventListener('error', () => {
xhrRef.current = null
setPhase('error')
setError(GENERIC_ERROR_MESSAGE)
})
xhr.send(file)
}
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>
{/* The frontend prose base styles every `h2` with a bottom border;
the approved dialog header is a single block (scene 4). */}
<DialogTitle className="border-b-0 pb-0">
{uploading ? 'Enviando gravação' : 'Enviar gravação'}
</DialogTitle>
<DialogDescription>
{uploading
? 'Mantenha esta janela aberta até concluir.'
: 'Adicione um arquivo local ao acervo.'}
</DialogDescription>
</DialogHeader>
{uploading ? (
<div className="rounded-lg border bg-stone-50 p-4">
<div className="flex items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-white text-muted-foreground ring-1 ring-border">
<VideoIcon className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{file?.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{title.trim()}
{recordedAt ? ` · ${recordedAt.split('-').reverse().join('/')}` : ''}
</p>
</div>
<span className="ml-auto text-sm font-semibold tabular-nums">{progress}%</span>
</div>
<div
className="mt-4 h-2 overflow-hidden rounded-full bg-muted"
aria-label={`${progress}% enviado`}
>
<div
className="h-full rounded-full bg-primary transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
<div className="mt-2 flex justify-between text-xs text-muted-foreground">
<span>Enviando arquivo…</span>
<span>{progress}%</span>
</div>
<p className="mt-4 rounded-lg bg-muted/70 p-3 text-xs leading-5 text-muted-foreground">
Quando o envio terminar, a gravação aparecerá na lista como “Processando”.
</p>
</div>
) : (
<div className="flex flex-col gap-4">
<div>
<Label htmlFor="recording-file">Arquivo de vídeo</Label>
<input
ref={fileInputRef}
id="recording-file"
type="file"
accept="video/*,.mkv,.mov,.mp4"
className="sr-only"
onChange={onFileChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={cn(
'mt-1.5 flex min-h-28 w-full flex-col items-center justify-center rounded-lg border border-dashed px-4 text-center transition-colors',
fileError
? 'border-destructive bg-red-50/40'
: 'border-input bg-stone-50 hover:bg-stone-100',
)}
>
{file ? (
<>
<FileIcon
className={cn(
'size-6',
fileError ? 'text-destructive' : 'text-muted-foreground',
)}
aria-hidden="true"
/>
<span className="mt-2 text-sm font-medium">{file.name}</span>
<span className="mt-1 text-xs text-muted-foreground">
{formatRecordingFileSize(file.size)}
</span>
</>
) : (
<>
<UploadIcon className="size-6 text-muted-foreground" aria-hidden="true" />
<span className="mt-2 text-sm font-medium">Selecionar arquivo local</span>
<span className="mt-1 text-xs text-muted-foreground">
Vídeos até {RECORDING_MAX_SIZE_LABEL} · MP4, MOV, MKV e formatos compatíveis
</span>
</>
)}
</button>
{fileError ? (
<>
<Alert variant="destructive" className="mt-2 py-2">
<CircleAlertIcon aria-hidden="true" />
<AlertDescription className="text-xs">{fileError}</AlertDescription>
</Alert>
<p className="mt-2 text-xs text-muted-foreground">
Vídeos até {RECORDING_MAX_SIZE_LABEL} · MP4, MOV, MKV e formatos compatíveis
</p>
</>
) : null}
</div>
<div
className={cn('flex flex-col gap-4', fileError && 'pointer-events-none opacity-60')}
aria-disabled={fileError ? true : undefined}
>
<div>
<Label htmlFor="recording-title">
Título <span className="text-destructive">*</span>
</Label>
<Input
id="recording-title"
value={title}
maxLength={RECORDING_TITLE_MAX_LENGTH}
onChange={(event) => {
setTitle(event.target.value)
if (titleError) setTitleError(null)
}}
placeholder="Título da gravação"
className="mt-1.5 min-h-11"
aria-invalid={titleError ? true : undefined}
/>
{titleError ? (
<p role="alert" className="mt-1 text-xs text-destructive">
{titleError}
</p>
) : null}
</div>
<div>
<Label htmlFor="recording-date">Data da gravação</Label>
<Input
id="recording-date"
type="date"
value={recordedAt}
onChange={(event) => setRecordedAt(event.target.value)}
className="mt-1.5 min-h-11"
/>
</div>
</div>
</div>
)}
{error ? (
<Alert variant="destructive" className="py-2" role="alert">
<CircleAlertIcon aria-hidden="true" />
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
<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={Boolean(fileError)}
onClick={submit}
>
<UploadIcon data-icon="inline-start" aria-hidden="true" />
Enviar
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -16,7 +16,9 @@ const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
const PaginationContent = ({ className, ...props }: React.ComponentProps<'ul'>) => (
<ul
data-slot="pagination-content"
className={cn('flex items-center gap-0.5', className)}
// `m-0 list-none`: the frontend prose base styles every `ul` with disc
// markers and margins, which leaked dots onto the campaign pagination.
className={cn('m-0 flex list-none items-center gap-0.5', className)}
{...props}
/>
)

View file

@ -34,6 +34,20 @@ function DialogOverlay({
)
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
}
function DialogContent({
className,
children,
@ -101,4 +115,13 @@ function DialogDescription({
)
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle }
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
}

View file

@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react'
import type { Metadata } from 'next'
import {
CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES,
CAMPAIGN_COMMUNICATION_CORTES,
CAMPAIGN_COMMUNICATION_REELS,
CAMPAIGN_CONTACTS_HOME,
@ -120,7 +121,10 @@ export const campaignPageChromeCatalog = {
},
acervo: {
title: 'Acervo de falas',
subtitle: 'Discursos do Deputado Jorge Solla na Câmara.',
subtitle: 'Busque nas falas da Câmara ou nas gravações da equipe.',
},
gravacoes: {
title: 'Gravações enviadas',
},
cortes: {
title: 'Cortes',
@ -304,6 +308,13 @@ const pathRules: PathRule[] = [
match: (pathname) => pathname === CAMPAIGN_COMMUNICATION_REELS,
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.reels),
},
{
// C199 — same C168 lesson: the generic acervo-detail regex would read
// `gravacoes` as an id. The list redirects to `?source=enviadas`; this
// entry only backs the transient alias.
match: (pathname) => pathname === CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES,
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.gravacoes),
},
{
match: (pathname) => /^\/campanha\/comunicacao\/acervo\/[^/]+$/.test(pathname),
resolve: () => null,

View file

@ -50,4 +50,27 @@ export const campaignSpeechCutDeleteHref = (id: number): string =>
export const campaignSpeechCutRetryHref = (id: number): string =>
`${campaignSpeechCutDetailHref(id)}/retry`
/** C199 — "Gravações enviadas" source inside the acervo (list + `/<id>` detail). */
export const CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES =
`${CAMPAIGN_COMMUNICATION_ACERVO}/gravacoes` as const
const campaignRecordingDetailHref = (id: number): string =>
`${CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES}/${id}`
/** C199 — JSON endpoints of the recording detail (send/status/retry/delete). */
export const CAMPAIGN_RECORDING_UPLOAD_HREF =
`${CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES}/enviar` as const
export const CAMPAIGN_RECORDING_STATUS_HREF =
`${CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES}/status` as const
export const campaignRecordingFileHref = (id: number, download = false): string =>
`${campaignRecordingDetailHref(id)}/arquivo${download ? '?download=1' : ''}`
export const campaignRecordingRetryHref = (id: number): string =>
`${campaignRecordingDetailHref(id)}/retry`
export const campaignRecordingDeleteHref = (id: number): string =>
`${campaignRecordingDetailHref(id)}/apagar`
export const CAMPAIGN_UPDATES_HREF = '/campanha/atualizacoes' as const

82
src/lib/privateMedia.ts Normal file
View file

@ -0,0 +1,82 @@
/**
* C193/C199 — pure response rules of a private media artifact: the safe content
* type, the content disposition and the range/private headers. No I/O and no
* `server-only`: the streaming utility and the unit tests share this module.
*
* The owner serves every private upload collection (`reelMedia`, `recordingMedia`)
* through the authenticated `/campanha` routes; the public `media` contract
* never touches this module.
*/
/**
* Only these may render inline; anything else is forced to download. C199 adds
* the video containers the recordings actually arrive in (MOV/MKV/WebM) — all
* inert media types, never markup executed in the campaign origin.
*/
const PRIVATE_MEDIA_INLINE_MIME_TYPES = [
'video/mp4',
'video/quicktime',
'video/x-matroska',
'video/webm',
'audio/mpeg',
'image/png',
'image/jpeg',
] as const
export const PRIVATE_MEDIA_FALLBACK_MIME_TYPE = 'application/octet-stream'
export const PRIVATE_MEDIA_CACHE_CONTROL = 'private, no-store'
export type PrivateMediaRange = {
status: number
headers: Record<string, string>
}
const isInlineSafe = (mimeType: unknown): mimeType is string =>
typeof mimeType === 'string' &&
(PRIVATE_MEDIA_INLINE_MIME_TYPES as readonly string[]).includes(mimeType)
/**
* The served content type is the stored one only when it is safe to render
* inline; everything else degrades to `application/octet-stream` (never an
* attacker-chosen type executed in the campaign origin).
*/
export const privateMediaContentType = (mimeType: string | null | undefined): string =>
isInlineSafe(mimeType) ? mimeType : PRIVATE_MEDIA_FALLBACK_MIME_TYPE
/** Both `filename` and `filename*` (RFC 5987) so non-ASCII names survive. */
export const privateMediaContentDisposition = (
filename: string | null | undefined,
download: boolean,
): string => {
const name = filename?.trim() || 'arquivo'
const asciiFallback = name.replace(/[^\w.-]+/g, '_')
const disposition = download ? 'attachment' : 'inline'
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(name)}`
}
/**
* The response headers of one artifact: the range headers computed from the
* real file size plus the private streaming contract. Pure.
*/
export const privateMediaHeaders = ({
range,
mimeType,
filename,
download,
}: {
range: PrivateMediaRange
mimeType: string | null | undefined
filename: string | null | undefined
download: boolean
}): Headers => {
const headers = new Headers(range.headers)
const contentType = privateMediaContentType(mimeType)
const forceDownload = download || contentType === PRIVATE_MEDIA_FALLBACK_MIME_TYPE
headers.set('Content-Type', contentType)
headers.set('Content-Disposition', privateMediaContentDisposition(filename, forceDownload))
headers.set('Cache-Control', PRIVATE_MEDIA_CACHE_CONTROL)
headers.set('X-Content-Type-Options', 'nosniff')
if (forceDownload) headers.set('Content-Security-Policy', "default-src 'none'")
return headers
}

186
src/lib/recording.ts Normal file
View file

@ -0,0 +1,186 @@
/**
* C199 — pure rules of an uploaded recording: the status/step vocabulary, the
* upload limits and the honest failure copy. No I/O and no `server-only`: the
* client dialog, the JSON routes, the job and the unit tests share this module.
*/
import {
CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES,
campaignRecordingDeleteHref,
campaignRecordingFileHref,
campaignRecordingRetryHref,
} from '@/lib/campaignPaths'
import { formatSpeechClock, formatSpeechDate } from '@/lib/speechClock'
/** Owner of the private upload collection slug (collection, config and routes). */
export const RECORDING_MEDIA_SLUG = 'recordingMedia' as const
export const RECORDING_STATUSES = ['uploading', 'processing', 'ready', 'failed'] as const
export type RecordingStatus = (typeof RECORDING_STATUSES)[number]
export const recordingStatusLabels: Record<RecordingStatus, string> = {
uploading: 'Enviando',
processing: 'Processando',
ready: 'Pronto',
failed: 'Falhou',
}
export const isRecordingStatus = (value: unknown): value is RecordingStatus =>
RECORDING_STATUSES.includes(value as RecordingStatus)
/** One owner for the persisted field limits (collection, schema and dialog). */
export const RECORDING_TITLE_MAX_LENGTH = 200
/** Upload ceiling: hours of plenary are the real case, 4 GiB is the guard. */
export const RECORDING_MAX_BYTES = 4 * 1024 * 1024 * 1024
export const RECORDING_MAX_SIZE_LABEL = '4 GB'
const RECORDING_ACCEPTED_MIME_PREFIX = 'video/'
export const RECORDING_STEPS = ['extracting', 'transcribing', 'saving'] as const
export type RecordingStep = (typeof RECORDING_STEPS)[number]
export const recordingStepLabels: Record<RecordingStep, string> = {
extracting: 'Preparando o áudio da gravação',
transcribing: 'Transcrevendo a gravação',
saving: 'Guardando a transcrição',
}
const isRecordingStep = (value: unknown): value is RecordingStep =>
RECORDING_STEPS.includes(value as RecordingStep)
/**
* C199 — 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 RECORDING_FAILURE_INTERRUPTED = 'O processamento foi interrompido antes de terminar.'
const RECORDING_FAILURE_EXTRACTING_COPY = 'Não foi possível preparar o áudio da gravação.'
const RECORDING_FAILURE_TRANSCRIBING_COPY = 'Não foi possível transcrever a gravação.'
const RECORDING_FAILURE_SAVING_COPY = 'Não foi possível guardar a transcrição.'
const RECORDING_FAILURE_UNKNOWN_COPY = 'Não foi possível transcrever a gravação.'
/**
* Maps the stored cause of a failed recording 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 recordingFailureMessage = ({
error,
step,
}: {
error?: string | null
step?: RecordingStep | null
}): string | null => {
const stored = error?.trim()
if (!stored) return null
if (stored === RECORDING_FAILURE_INTERRUPTED) return RECORDING_FAILURE_INTERRUPTED
if (step === 'extracting') return RECORDING_FAILURE_EXTRACTING_COPY
if (step === 'transcribing') return RECORDING_FAILURE_TRANSCRIBING_COPY
if (step === 'saving') return RECORDING_FAILURE_SAVING_COPY
return RECORDING_FAILURE_UNKNOWN_COPY
}
/**
* The recording file (player/download) only exists once the upload stream
* finished: `uploading` has no stored object yet, every other status serves it.
*/
export const canServeRecordingMedia = (status: RecordingStatus): boolean => status !== 'uploading'
/** The retry button only applies to a failed transcription. */
export const canRetryRecording = (status: RecordingStatus): boolean => status === 'failed'
/** Errors of the upload form (client validation). Wire messages live in schemas. */
export const RECORDING_FILE_REQUIRED_MESSAGE = 'Escolha um arquivo de vídeo para enviar.'
export const RECORDING_TITLE_REQUIRED_MESSAGE = 'Informe um título para a gravação.'
export const RECORDING_FILE_TYPE_MESSAGE =
'Envie um arquivo de vídeo (MP4, MOV, MKV e formatos compatíveis).'
/** A missing type is accepted: `.mkv` often arrives with no `type` from the OS. */
export const recordingFileTypeAllowed = (mimeType: string | null | undefined): boolean =>
!mimeType || mimeType.startsWith(RECORDING_ACCEPTED_MIME_PREFIX)
/** Basename without path parts or unsafe characters, keeping the extension. */
export const sanitizeRecordingFilename = (filename: string): string => {
const base = filename.split(/[\\/]/).pop() ?? ''
const safe = base.replace(/[^\w.-]+/g, '_').replace(/^[._]+/, '')
return safe || 'gravacao'
}
/** Human-readable file size in pt-BR (`4,6 GB`, `812 MB`, `900 KB`, `12 B`). */
export const formatRecordingFileSize = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes < 0) return '—'
if (bytes >= 1024 ** 3)
return `${(bytes / 1024 ** 3).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} GB`
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2).toLocaleString('pt-BR')} MB`
if (bytes >= 1024) return `${Math.round(bytes / 1024).toLocaleString('pt-BR')} KB`
return `${Math.round(bytes)} B`
}
export const recordingTooLargeMessage = (bytes: number): string =>
`Este arquivo tem ${formatRecordingFileSize(bytes)}. Escolha um vídeo de até ${RECORDING_MAX_SIZE_LABEL}.`
export const recordingUploadTooLargeMessage = `O arquivo excede o limite de ${RECORDING_MAX_SIZE_LABEL}.`
export type RecordingViewModel = {
id: number
title: string
status: RecordingStatus
step: RecordingStep | null
/** `dd/mm/aaaa` when the row has a date; null otherwise. */
recordedAtLabel: string | null
durationLabel: string | null
/** Raw duration for the detail player; null while it is unknown. */
durationSeconds: number | null
detailHref: string
fileHref: string
downloadHref: string
retryHref: string
deleteHref: string
}
type RecordingViewRecord = {
id: number
title?: string | null
status: string
step?: string | null
recordedAt?: string | null
durationSeconds?: number | null
}
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 normalizedStatus = (value: string): RecordingStatus =>
isRecordingStatus(value) ? value : 'failed'
/**
* Internal/status wire view of a recording. `error` and `createdBy` never pass
* through here: the detail receives only what it renders — a failure becomes
* the mapped `failureMessage`, never the raw detail.
*/
export const toRecordingViewModel = (record: RecordingViewRecord): RecordingViewModel => {
const status = normalizedStatus(record.status)
return {
id: record.id,
title: record.title?.trim() || `Gravação ${record.id}`,
status,
step: isRecordingStep(record.step) ? record.step : null,
recordedAtLabel: dateLabelOf(record.recordedAt),
durationLabel: durationLabelOf(record.durationSeconds),
durationSeconds:
typeof record.durationSeconds === 'number' && Number.isFinite(record.durationSeconds)
? Math.max(0, record.durationSeconds)
: null,
detailHref: `${CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES}/${record.id}`,
fileHref: campaignRecordingFileHref(record.id),
downloadHref: campaignRecordingFileHref(record.id, true),
retryHref: campaignRecordingRetryHref(record.id),
deleteHref: campaignRecordingDeleteHref(record.id),
}
}

View file

@ -0,0 +1,143 @@
/**
* C199 — pure transcription rules of an uploaded recording: the ffmpeg command
* that extracts and splits the audio, the merge of per-chunk ASR segments into
* absolute timestamps, and the normalized search text persisted on the row.
* No I/O: the job is the only impure side and the unit tests read this module.
*/
import { normalizeForSearch } from '@/lib/speechSearch'
/** 20-minute chunks: ≈4,8 MB of 32 kbps mono audio, minutes from any provider cap. */
export const RECORDING_AUDIO_CHUNK_SECONDS = 20 * 60
const RECORDING_AUDIO_SAMPLE_RATE = 16_000
const RECORDING_AUDIO_BITRATE = '32k'
/** One ASR segment as the provider returns it, relative to its chunk. */
export type RecordingChunkSegment = {
start: number
end: number
text: string
}
/** One chunk of the transcription and the absolute offset it starts at. */
export type RecordingChunkTranscription = {
offsetSeconds: number
segments: readonly RecordingChunkSegment[]
}
/** One persisted `recordingSegment` row, in absolute seconds. */
export type RecordingTranscriptSegment = {
order: number
startSeconds: number
endSeconds: number
text: string
}
/**
* Extracts a mono 16 kHz MP3 and splits it into fixed chunks in one ffmpeg
* pass. Args are returned as an array — never a shell string; `outputPattern`
* carries the `%03d` sequence ffmpeg replaces per chunk.
*/
export const buildRecordingAudioFfmpegArgs = ({
inputPath,
outputPattern,
}: {
inputPath: string
outputPattern: string
}): string[] => [
'-nostdin',
'-hide_banner',
'-y',
'-i',
inputPath,
'-vn',
'-ac',
'1',
'-ar',
String(RECORDING_AUDIO_SAMPLE_RATE),
'-c:a',
'libmp3lame',
'-b:a',
RECORDING_AUDIO_BITRATE,
'-f',
'segment',
'-segment_time',
String(RECORDING_AUDIO_CHUNK_SECONDS),
'-reset_timestamps',
'1',
outputPattern,
]
/**
* Merges every chunk into one ordered transcript: each segment is shifted by
* its chunk offset, empty text is dropped and a degenerate span (end before
* start) is clamped so the clickable transcript never breaks the player.
* Order is assigned after the merge, so the persisted sequence is contiguous.
*/
export const mergeChunkTranscriptions = (
chunks: readonly RecordingChunkTranscription[],
): RecordingTranscriptSegment[] => {
const merged: RecordingTranscriptSegment[] = []
for (const chunk of chunks) {
for (const segment of chunk.segments) {
const text = segment.text.trim()
if (!text) continue
const startSeconds = Math.max(0, chunk.offsetSeconds + segment.start)
const endSeconds = Math.max(startSeconds, chunk.offsetSeconds + segment.end)
merged.push({
order: merged.length,
startSeconds,
endSeconds,
text,
})
}
}
return merged
}
/** The concatenated transcript used by the recording's textual search. */
export const recordingSearchText = (segments: readonly { text: string }[]): string =>
normalizeForSearch(
segments
.map((segment) => segment.text)
.join(' ')
.trim(),
)
type NormalizedTranscription = {
segments: RecordingChunkSegment[]
durationSeconds: number | null
}
/**
* Normalizes a Deep Infra transcription response (ported from the Câmara CLI's
* `normalizeTranscription`): accepts the OpenAI-compat shape
* (`segments[{start,end,text}]`) and the native whisper shape
* (`chunks[{timestamp:[start,end],text}]`). Invalid entries are dropped; a
* missing `duration` stays null instead of guessing.
*/
export const normalizeRecordingTranscription = (json: unknown): NormalizedTranscription => {
const data = json && typeof json === 'object' ? (json as Record<string, unknown>) : {}
const rawSegments = Array.isArray(data.segments)
? data.segments
: Array.isArray(data.chunks)
? data.chunks
: []
const segments: RecordingChunkSegment[] = []
for (const raw of rawSegments) {
const segment = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
const timestamps = Array.isArray(segment.timestamp) ? segment.timestamp : null
const start = Number(segment.start ?? timestamps?.[0])
const end = Number(segment.end ?? timestamps?.[1])
const text = typeof segment.text === 'string' ? segment.text.trim() : ''
if (!Number.isFinite(start) || !Number.isFinite(end) || text === '') continue
segments.push({ start, end, text })
}
const rawDuration = Number(data.duration)
return {
segments,
durationSeconds: Number.isFinite(rawDuration) && rawDuration >= 0 ? rawDuration : null,
}
}

View file

@ -1,66 +0,0 @@
/**
* C193 — pure response rules of a private reel artifact: the safe content type,
* the content disposition and the range/private headers. No I/O and no
* `server-only`: the streaming utility and the unit tests share this module.
*/
/** Only these may render inline; anything else is forced to download. */
const REEL_MEDIA_INLINE_MIME_TYPES = ['video/mp4', 'audio/mpeg', 'image/png', 'image/jpeg'] as const
export const REEL_MEDIA_FALLBACK_MIME_TYPE = 'application/octet-stream'
export const REEL_MEDIA_CACHE_CONTROL = 'private, no-store'
export type ReelMediaRange = {
status: number
headers: Record<string, string>
}
const isInlineSafe = (mimeType: unknown): mimeType is string =>
typeof mimeType === 'string' &&
(REEL_MEDIA_INLINE_MIME_TYPES as readonly string[]).includes(mimeType)
/**
* The served content type is the stored one only when it is safe to render
* inline; everything else degrades to `application/octet-stream` (never an
* attacker-chosen type executed in the campaign origin).
*/
export const reelMediaContentType = (mimeType: string | null | undefined): string =>
isInlineSafe(mimeType) ? mimeType : REEL_MEDIA_FALLBACK_MIME_TYPE
/** Both `filename` and `filename*` (RFC 5987) so non-ASCII names survive. */
export const reelMediaContentDisposition = (
filename: string | null | undefined,
download: boolean,
): string => {
const name = filename?.trim() || 'arquivo'
const asciiFallback = name.replace(/[^\w.-]+/g, '_')
const disposition = download ? 'attachment' : 'inline'
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(name)}`
}
/**
* The response headers of one artifact: the range headers computed from the
* real file size plus the private streaming contract. Pure.
*/
export const reelMediaHeaders = ({
range,
mimeType,
filename,
download,
}: {
range: ReelMediaRange
mimeType: string | null | undefined
filename: string | null | undefined
download: boolean
}): Headers => {
const headers = new Headers(range.headers)
const contentType = reelMediaContentType(mimeType)
const forceDownload = download || contentType === REEL_MEDIA_FALLBACK_MIME_TYPE
headers.set('Content-Type', contentType)
headers.set('Content-Disposition', reelMediaContentDisposition(filename, forceDownload))
headers.set('Cache-Control', REEL_MEDIA_CACHE_CONTROL)
headers.set('X-Content-Type-Options', 'nosniff')
if (forceDownload) headers.set('Content-Security-Policy', "default-src 'none'")
return headers
}

View file

@ -0,0 +1,62 @@
import { z } from 'zod'
import { RECORDING_TITLE_MAX_LENGTH, RECORDING_TITLE_REQUIRED_MESSAGE } from '@/lib/recording'
import { trimmedOptionalText } from '@/lib/schemas/primitives'
/** The acervo gate refused the actor — one literal shared with the vertical. */
export const RECORDING_FORBIDDEN_MESSAGE = 'Seu perfil não tem acesso ao acervo de comunicação.'
/** The recording does not exist (or the actor cannot read it). */
export const RECORDING_NOT_FOUND_MESSAGE = 'Gravação não encontrada.'
/** Retry only applies to a failed recording. */
export const RECORDING_RETRY_NOT_FAILED_MESSAGE =
'Só é possível reprocessar uma gravação que falhou.'
/** Transport/storage failure (or anything unmapped) of a JSON action. */
export const RECORDING_GENERIC_ERROR_MESSAGE =
'Não foi possível concluir a ação. Verifique seu acesso e tente novamente.'
/** The recording upload arrived without a file name. */
export const RECORDING_FILE_NAME_MESSAGE = 'Nome de arquivo inválido.'
/** Filename sanity: no path parts, no empties — the temp file keeps the extension. */
const uploadFilename = z
.string()
.trim()
.min(1, RECORDING_FILE_NAME_MESSAGE)
.max(255, RECORDING_FILE_NAME_MESSAGE)
.refine((value) => !value.includes('/') && !value.includes('\\'), RECORDING_FILE_NAME_MESSAGE)
/** Metadata of the raw-body upload (`POST .../gravacoes/enviar`). */
export const recordingUploadMetadataSchema = z.object({
title: z
.string()
.trim()
.min(1, RECORDING_TITLE_REQUIRED_MESSAGE)
.max(
RECORDING_TITLE_MAX_LENGTH,
`O título deve ter até ${RECORDING_TITLE_MAX_LENGTH} caracteres.`,
),
recordedAt: trimmedOptionalText(32),
filename: uploadFilename,
})
export type RecordingUploadMetadata = z.infer<typeof recordingUploadMetadataSchema>
/** Retry the transcription of a failed recording. */
export const recordingRetryRequestSchema = z.object({
recordingId: z.number().int().positive(),
})
/** Delete one recording (row + segments + private media). */
export const recordingDeleteRequestSchema = z.object({
recordingId: z.number().int().positive(),
})
/** Poll the statuses of the visible recordings (bounded batch). */
export const recordingStatusRequestSchema = z.object({
recordingIds: z.array(z.number().int().positive()).min(1).max(50),
})
export type RecordingStatusRequest = z.infer<typeof recordingStatusRequestSchema>

View file

@ -36,3 +36,13 @@ export const formatSpeechDate = (speechAt: string): string => {
const [, year, month, day] = match
return `${day}/${month}/${year}`
}
/**
* C199 — seek offset from a detail URL (`?t=`), non-negative seconds or null.
* Shared by the speech and recording details (the query contract is the same).
*/
export const parseSeekSeconds = (raw: string | undefined): number | null => {
if (raw === undefined) return null
const seconds = Number(raw)
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null
}

View file

@ -77,6 +77,29 @@ export const matchesSearchTerms = (text: string, query: string): boolean => {
return terms.every((term) => normalized.includes(term))
}
/** Any record carrying the searchable text of one segment (speech/recording). */
export type SearchableSegment = { text: string }
/**
* C154/C199 — the first segment that carries the whole query (all terms, any
* order), else the first one carrying any term. Shared by the speech acervo and
* the uploaded recordings, which pick the excerpt of a result the same way.
*/
export const pickMatchingSegment = <Segment extends SearchableSegment>(
segments: readonly Segment[],
query: string | undefined,
): Segment | undefined => {
const q = query?.trim()
if (!q || segments.length === 0) return undefined
const allTerms = segments.find((segment) => matchesSearchTerms(segment.text, q))
if (allTerms) return allTerms
const terms = speechSearchTerms(q)
return segments.find((segment) => {
const normalized = normalizeForSearch(segment.text)
return terms.some((term) => normalized.includes(term))
})
}
/** Every occurrence of every term, mapped back to source offsets and merged. */
export const findHighlightRanges = (text: string, query: string): SpeechHighlightRange[] => {
const terms = speechSearchTerms(query)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,111 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* C199 — tables of the uploaded recordings. The two trigram indexes are
* hand-written (not a Payload field/schema change) on the same pattern as
* `20260913_001200_add_speech_segment_trgm_index`: the recordings search runs
* `ILIKE '%term%'` on the normalized `search_text`, and the GIN index makes it
* sargable. Only those indexes are idempotent (`IF NOT EXISTS`); the types and
* tables, like every Payload migration, run once.
*/
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_recording_status" AS ENUM('uploading', 'processing', 'ready', 'failed');
CREATE TYPE "public"."enum_recording_step" AS ENUM('extracting', 'transcribing', 'saving');
CREATE TABLE "recording" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar NOT NULL,
"recorded_at" timestamp(3) with time zone,
"status" "enum_recording_status" DEFAULT 'uploading' NOT NULL,
"step" "enum_recording_step",
"media_id" integer,
"duration_seconds" numeric,
"search_text" varchar,
"error" 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 "recording_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
);
CREATE TABLE "recording_segment" (
"id" serial PRIMARY KEY NOT NULL,
"recording_id" integer NOT NULL,
"order" numeric NOT NULL,
"start_seconds" numeric NOT NULL,
"end_seconds" numeric NOT NULL,
"text" varchar NOT NULL,
"search_text" 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
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "recording_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "recording_media_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "recording_segment_id" integer;
ALTER TABLE "recording" ADD CONSTRAINT "recording_media_id_recording_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "public"."recording_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "recording" ADD CONSTRAINT "recording_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;
ALTER TABLE "recording_segment" ADD CONSTRAINT "recording_segment_recording_id_recording_id_fk" FOREIGN KEY ("recording_id") REFERENCES "public"."recording"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "recording_recorded_at_idx" ON "recording" USING btree ("recorded_at");
CREATE INDEX "recording_status_idx" ON "recording" USING btree ("status");
CREATE INDEX "recording_media_idx" ON "recording" USING btree ("media_id");
CREATE INDEX "recording_created_by_idx" ON "recording" USING btree ("created_by_id");
CREATE INDEX "recording_updated_at_idx" ON "recording" USING btree ("updated_at");
CREATE INDEX "recording_created_at_idx" ON "recording" USING btree ("created_at");
CREATE INDEX "recording_media_updated_at_idx" ON "recording_media" USING btree ("updated_at");
CREATE INDEX "recording_media_created_at_idx" ON "recording_media" USING btree ("created_at");
CREATE UNIQUE INDEX "recording_media_filename_idx" ON "recording_media" USING btree ("filename");
CREATE INDEX "recording_segment_recording_idx" ON "recording_segment" USING btree ("recording_id");
CREATE INDEX "recording_segment_updated_at_idx" ON "recording_segment" USING btree ("updated_at");
CREATE INDEX "recording_segment_created_at_idx" ON "recording_segment" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_recording_fk" FOREIGN KEY ("recording_id") REFERENCES "public"."recording"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_recording_media_fk" FOREIGN KEY ("recording_media_id") REFERENCES "public"."recording_media"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_recording_segment_fk" FOREIGN KEY ("recording_segment_id") REFERENCES "public"."recording_segment"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_recording_id_idx" ON "payload_locked_documents_rels" USING btree ("recording_id");
CREATE INDEX "payload_locked_documents_rels_recording_media_id_idx" ON "payload_locked_documents_rels" USING btree ("recording_media_id");
CREATE INDEX "payload_locked_documents_rels_recording_segment_id_idx" ON "payload_locked_documents_rels" USING btree ("recording_segment_id");
CREATE INDEX IF NOT EXISTS "recording_search_text_trgm_idx" ON "recording" USING gin ("search_text" gin_trgm_ops);
CREATE INDEX IF NOT EXISTS "recording_segment_search_text_trgm_idx" ON "recording_segment" 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 "recording_search_text_trgm_idx";
DROP INDEX IF EXISTS "recording_segment_search_text_trgm_idx";
ALTER TABLE "recording" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "recording_media" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "recording_segment" DISABLE ROW LEVEL SECURITY;
DROP TABLE "recording" CASCADE;
DROP TABLE "recording_media" CASCADE;
DROP TABLE "recording_segment" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_recording_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_recording_media_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_recording_segment_fk";
DROP INDEX "payload_locked_documents_rels_recording_id_idx";
DROP INDEX "payload_locked_documents_rels_recording_media_id_idx";
DROP INDEX "payload_locked_documents_rels_recording_segment_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "recording_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "recording_media_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "recording_segment_id";
DROP TYPE "public"."enum_recording_status";
DROP TYPE "public"."enum_recording_step";`)
}

View file

@ -74,6 +74,7 @@ import * as migration_20260915_122356_add_speech_cut from './20260915_122356_add
import * as migration_20260915_142058_activity_google_import_link from './20260915_142058_activity_google_import_link';
import * as migration_20260918_201009_add_reel from './20260918_201009_add_reel';
import * as migration_20260919_002605_add_reel_source_hash from './20260919_002605_add_reel_source_hash';
import * as migration_20260919_003440_add_recording from './20260919_003440_add_recording';
export const migrations = [
{
@ -456,4 +457,9 @@ export const migrations = [
down: migration_20260919_002605_add_reel_source_hash.down,
name: '20260919_002605_add_reel_source_hash'
},
{
up: migration_20260919_003440_add_recording.up,
down: migration_20260919_003440_add_recording.down,
name: '20260919_003440_add_recording'
},
];

View file

@ -91,6 +91,9 @@ export interface Config {
speechCut: SpeechCut;
reel: Reel;
reelMedia: ReelMedia;
recording: Recording;
recordingMedia: RecordingMedia;
recordingSegment: RecordingSegment;
calendarFeed: CalendarFeed;
googleCalendarSync: GoogleCalendarSync;
electionTally: ElectionTally;
@ -137,6 +140,9 @@ export interface Config {
speechCut: SpeechCutSelect<false> | SpeechCutSelect<true>;
reel: ReelSelect<false> | ReelSelect<true>;
reelMedia: ReelMediaSelect<false> | ReelMediaSelect<true>;
recording: RecordingSelect<false> | RecordingSelect<true>;
recordingMedia: RecordingMediaSelect<false> | RecordingMediaSelect<true>;
recordingSegment: RecordingSegmentSelect<false> | RecordingSegmentSelect<true>;
calendarFeed: CalendarFeedSelect<false> | CalendarFeedSelect<true>;
googleCalendarSync: GoogleCalendarSyncSelect<false> | GoogleCalendarSyncSelect<true>;
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
@ -1139,6 +1145,86 @@ export interface ReelMedia {
focalX?: number | null;
focalY?: number | null;
}
/**
* Gravações próprias da equipe no acervo. O arquivo é privado; a transcrição é somente leitura.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "recording".
*/
export interface Recording {
id: number;
title: string;
/**
* Data em que a gravação foi feita (opcional).
*/
recordedAt?: string | null;
status: 'uploading' | 'processing' | 'ready' | 'failed';
/**
* Progresso honesto da transcrição em andamento.
*/
step?: ('extracting' | 'transcribing' | 'saving') | null;
/**
* O arquivo privado que o player toca e o download entrega.
*/
media?: (number | null) | RecordingMedia;
/**
* Derivada do áudio transcrito.
*/
durationSeconds?: number | null;
/**
* Concatenação normalizada dos segmentos (sem acentos, minúsculas).
*/
searchText?: string | null;
/**
* Motivo interno da falha; nunca vai à pessoa com o detalhe cru.
*/
error?: string | null;
createdBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* Arquivos privados das gravações enviadas ao acervo. Só abrem com login da campanha.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "recordingMedia".
*/
export interface RecordingMedia {
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;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "recordingSegment".
*/
export interface RecordingSegment {
id: number;
recording: number | Recording;
order: number;
startSeconds: number;
endSeconds: number;
text: string;
/**
* Derivado do texto (sem acentos, minúsculas) para a busca por palavra.
*/
searchText: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "calendarFeed".
@ -1696,6 +1782,18 @@ export interface PayloadLockedDocument {
relationTo: 'reelMedia';
value: number | ReelMedia;
} | null)
| ({
relationTo: 'recording';
value: number | Recording;
} | null)
| ({
relationTo: 'recordingMedia';
value: number | RecordingMedia;
} | null)
| ({
relationTo: 'recordingSegment';
value: number | RecordingSegment;
} | null)
| ({
relationTo: 'calendarFeed';
value: number | CalendarFeed;
@ -2315,6 +2413,55 @@ export interface ReelMediaSelect<T extends boolean = true> {
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "recording_select".
*/
export interface RecordingSelect<T extends boolean = true> {
title?: T;
recordedAt?: T;
status?: T;
step?: T;
media?: T;
durationSeconds?: T;
searchText?: T;
error?: T;
createdBy?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "recordingMedia_select".
*/
export interface RecordingMediaSelect<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` "recordingSegment_select".
*/
export interface RecordingSegmentSelect<T extends boolean = true> {
recording?: T;
order?: T;
startSeconds?: T;
endSeconds?: T;
text?: T;
searchText?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "calendarFeed_select".
@ -3050,6 +3197,9 @@ export interface TaskCreateCollectionExport {
| 'speechCut'
| 'reel'
| 'reelMedia'
| 'recording'
| 'recordingMedia'
| 'recordingSegment'
| 'calendarFeed'
| 'googleCalendarSync'
| 'electionTally'

View file

@ -32,6 +32,9 @@ import { Organization } from './collections/Organization'
import { Petition } from './collections/Petition'
import { Post } from './collections/Post'
import { PushSubscription } from './collections/PushSubscription'
import { Recording } from './collections/Recording'
import { RecordingMedia } from './collections/RecordingMedia'
import { RecordingSegment } from './collections/RecordingSegment'
import { Reel } from './collections/Reel'
import { ReelMedia } from './collections/ReelMedia'
import { Signature } from './collections/Signature'
@ -121,6 +124,9 @@ export default buildConfig({
SpeechCut,
Reel,
ReelMedia,
Recording,
RecordingMedia,
RecordingSegment,
CalendarFeed,
GoogleCalendarSync,
ElectionTally,
@ -169,6 +175,8 @@ export default buildConfig({
// C193 — private reel artifacts; without this entry they would
// fall back to the container's ephemeral disk in production.
reelMedia: true,
// C199 — private recording files; same failure mode if omitted.
recordingMedia: true,
},
bucket: mediaStorage.bucket,
config: {

View file

@ -0,0 +1,44 @@
// ---------------------------------------------------------------------------
// C199 — recordings uploaded into the communication acervo
// ---------------------------------------------------------------------------
import type { Access } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { getFreshCampaignUser, isPayloadAdmin } from '@/utilities/access/shared'
/**
* Readers of the uploaded recordings: Payload admin or campaign users with the
* `communicator`, `coordinator` or `candidate` role. Advisors and leaders are
* denied — the acervo is not municipality-scoped and not a staff area.
*/
export const canReadRecording: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}
/**
* Who may upload a recording (and the private media that belongs to it): the
* same acervo audience plus the Payload admin. Written as its own predicate,
* never an alias of `canReadRecording`, so a future widening of the read
* surface cannot silently grant upload.
*/
export const canUploadRecording: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}
/**
* Who may remove a recording (row + transcript + private media): its own
* predicate for the same reason as the upload one, mirroring `canDeleteReel`.
*/
export const canDeleteRecording: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}

View file

@ -1,5 +1,10 @@
import 'server-only'
import {
normalizeRecordingTranscription,
type RecordingChunkSegment,
} from '@/lib/recordingTranscription'
/**
* Transcribes a recorded voice clip to text via Deep Infra's OpenAI-compatible
* transcription endpoint (B173). Deep Infra serves Whisper large-v3 at
@ -10,6 +15,10 @@ import 'server-only'
* The audio is forwarded as a multipart `file` and never persisted anywhere —
* it lives only in this function's memory for the duration of the provider
* call (product requirement: B173 processes and discards the recording).
*
* C199 — `deepInfraTranscribeSegments` is the timestamped sibling used by the
* recording transcription job: same provider and key, `verbose_json` with
* segment timestamps (the shape the Câmara CLI already validates).
*/
export const DEEPINFRA_TRANSCRIBE_URL = 'https://api.deepinfra.com/v1/openai/audio/transcriptions'
@ -20,6 +29,8 @@ export type TranscribeAudioResult =
| { ok: true; text: string }
| { ok: false; error: string; status: number }
const TRANSCRIBE_ERROR = 'Não foi possível transcrever o áudio. Tente novamente.'
/**
* Sends the clip to Deep Infra Whisper large-v3 and returns the transcript.
* Never throws: every failure becomes `{ ok: false }` with a pt-BR message the
@ -47,7 +58,7 @@ export const deepInfraTranscribe = async (file: Blob): Promise<TranscribeAudioRe
if (!response.ok) {
return {
ok: false,
error: 'Não foi possível transcrever o áudio. Tente novamente.',
error: TRANSCRIBE_ERROR,
status: 502,
}
}
@ -57,7 +68,7 @@ export const deepInfraTranscribe = async (file: Blob): Promise<TranscribeAudioRe
if (!text) {
return {
ok: false,
error: 'Não foi possível transcrever o áudio. Tente novamente.',
error: TRANSCRIBE_ERROR,
status: 502,
}
}
@ -66,8 +77,61 @@ export const deepInfraTranscribe = async (file: Blob): Promise<TranscribeAudioRe
} catch {
return {
ok: false,
error: 'Não foi possível transcrever o áudio. Tente novamente.',
error: TRANSCRIBE_ERROR,
status: 502,
}
}
}
export type TranscribeSegmentsResult =
| { ok: true; segments: RecordingChunkSegment[]; durationSeconds: number | null }
| { ok: false; error: string; status: number }
const SEGMENTS_TIMEOUT_MS = 600_000
/**
* C199 — sends one audio chunk to Deep Infra Whisper with segment timestamps
* and returns the normalized segments (relative to the chunk). Never throws:
* the job maps `{ ok: false }` to the recording's `failed` state with the
* pt-BR message. The caller owns the offset merge and the chunk size.
*/
export const deepInfraTranscribeSegments = async (
file: Blob,
{
filename = 'chunk.mp3',
timeoutMs = SEGMENTS_TIMEOUT_MS,
}: { filename?: string; timeoutMs?: number } = {},
): Promise<TranscribeSegmentsResult> => {
const apiKey = process.env.DEEPINFRA_API_KEY
if (!apiKey) {
return { ok: false, error: 'Transcrição de áudio está indisponível no momento.', status: 503 }
}
const form = new FormData()
form.append('file', file, filename)
form.append('model', DEEPINFRA_WHISPER_MODEL)
form.append('language', DEEPINFRA_LANGUAGE)
form.append('response_format', 'verbose_json')
form.append('timestamp_granularities[]', 'segment')
try {
const response = await fetch(DEEPINFRA_TRANSCRIBE_URL, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
signal: AbortSignal.timeout(timeoutMs),
})
if (!response.ok) {
return { ok: false, error: TRANSCRIBE_ERROR, status: 502 }
}
const normalized = normalizeRecordingTranscription(await response.json())
if (normalized.segments.length === 0) {
return { ok: false, error: TRANSCRIBE_ERROR, status: 502 }
}
return { ok: true, ...normalized }
} catch {
return { ok: false, error: TRANSCRIBE_ERROR, status: 502 }
}
}

View file

@ -177,3 +177,9 @@ export {
} from '@/utilities/access/speeches'
export { canDeleteReel, canReadReel } from '@/utilities/access/reels'
export {
canDeleteRecording,
canReadRecording,
canUploadRecording,
} from '@/utilities/access/recordings'

View file

@ -0,0 +1,55 @@
import 'server-only'
import { execFile } from 'node:child_process'
/**
* C199 — the single owner of ffmpeg execution: the binary resolution, the
* stdout/stderr capture and the timeout policy. Extracted from the Câmara media
* pipeline so the cut/poster jobs and the recording transcription job share one
* implementation instead of a twin.
*
* `FFMPEG_PATH` lets a test/runtime point at a fake binary; the image ships
* `ffmpeg` (`Dockerfile`).
*/
const FFMPEG_MIN_TIMEOUT_MS = 30_000
const FFMPEG_MAX_TIMEOUT_MS = 300_000
const FFMPEG_MAX_BUFFER_BYTES = 8 * 1024 * 1024
const ffmpegBinary = (): string => process.env.FFMPEG_PATH?.trim() || 'ffmpeg'
/** Last-resort copy when an error carries no message; each job names its own. */
export const messageOf = (error: unknown, fallback: string): string =>
error instanceof Error && error.message !== '' ? error.message : fallback
/**
* Runs ffmpeg with a timeout scaled by the source duration (clamped to a
* floor/ceiling). `timeoutMs` overrides the clamp for jobs that already know
* the real budget (the recording extraction can run for hours).
*/
export const runFfmpeg = (
args: string[],
durationSeconds: number,
fallbackMessage = 'Falha ao processar o vídeo.',
timeoutMs?: number,
): Promise<void> =>
new Promise((resolve, reject) => {
const timeout =
timeoutMs ??
Math.min(FFMPEG_MAX_TIMEOUT_MS, Math.max(FFMPEG_MIN_TIMEOUT_MS, durationSeconds * 8_000))
execFile(
ffmpegBinary(),
args,
{ timeout, maxBuffer: FFMPEG_MAX_BUFFER_BYTES },
(error, _stdout, stderr) => {
if (!error) {
resolve()
return
}
const detail = String(stderr ?? '')
.trim()
.slice(-400)
reject(new Error(`${messageOf(error, fallbackMessage)}${detail ? ` — ${detail}` : ''}`))
},
)
})

View file

@ -1,35 +1,42 @@
import 'server-only'
import { GetObjectCommand, HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { createReadStream } from 'fs'
import { createReadStream, createWriteStream } from 'fs'
import { stat } from 'fs/promises'
import path from 'path'
import { getRangeRequestInfo } from 'payload/internal'
import { pipeline } from 'stream/promises'
import { REEL_MEDIA_SLUG } from '@/lib/reel'
import { REEL_MEDIA_CACHE_CONTROL, reelMediaHeaders, type ReelMediaRange } from '@/lib/reelMedia'
import type { ReelMedia } from '@/payload-types'
import {
PRIVATE_MEDIA_CACHE_CONTROL,
privateMediaHeaders,
type PrivateMediaRange,
} from '@/lib/privateMedia'
import { resolveS3StorageEnv } from '@/utilities/mediaStorage'
/**
* C193 — serves one private reel artifact as an HTTP response. The reel media
* never goes through the public `/api/media/file` proxy: the authenticated
* route under `/campanha` opens the object here and streams it back.
* C193/C199 — the single owner of private media I/O: streams one artifact as an
* HTTP response and downloads one object to a local file (the transcription job
* reads the stored recording this way). Nothing here goes through the public
* `/api/media/file` proxy — the authenticated route under `/campanha` and the
* job are the only callers.
*
* In production the object lives in the private Garage bucket (same S3_* envs
* as `media`); with no S3_* set (dev/test) it is read from the upload
* collection's local disk directory, mirroring Payload's own file handler.
*/
const DEFAULT_STATIC_DIR = REEL_MEDIA_SLUG
export type PrivateMediaFile = {
filename?: string | null
filesize?: number | null
mimeType?: string | null
}
type ReelMediaFile = Pick<ReelMedia, 'filename' | 'filesize' | 'mimeType'>
type PrivateMediaStorage = { bucket: string; client: S3Client }
type ReelMediaStorage = { bucket: string; client: S3Client }
let cachedStorage: PrivateMediaStorage | null | undefined
let cachedStorage: ReelMediaStorage | null | undefined
const reelMediaStorage = (): ReelMediaStorage | null => {
const privateMediaStorage = (): PrivateMediaStorage | null => {
if (cachedStorage !== undefined) return cachedStorage
const config = resolveS3StorageEnv(process.env)
@ -66,14 +73,25 @@ const isMissingObject = (error: unknown): boolean => {
)
}
type OpenedObject = { range: ReelMediaRange; body: BodyInit | null }
/** Resolves the on-disk path of an artifact, refusing any traversal attempt. */
const resolveLocalPath = (staticDir: string, filename: string): string => {
if (!staticDir) throw new Error(`Diretório de mídia privada ausente: ${filename}`)
const resolvedDir = path.resolve(staticDir)
const filePath = path.resolve(resolvedDir, filename)
if (!filePath.startsWith(resolvedDir + path.sep)) {
throw new Error('Nome de arquivo de mídia privada inválido.')
}
return filePath
}
type OpenedObject = { range: PrivateMediaRange; body: BodyInit | null }
const openS3Object = async ({
storage,
filename,
rangeHeader,
}: {
storage: ReelMediaStorage
storage: PrivateMediaStorage
filename: string
rangeHeader: string | null
}): Promise<OpenedObject> => {
@ -81,7 +99,7 @@ const openS3Object = async ({
new HeadObjectCommand({ Bucket: storage.bucket, Key: filename }),
)
const fileSize = head.ContentLength
if (fileSize == null) throw new Error(`Objeto de reel sem tamanho: ${filename}`)
if (fileSize == null) throw new Error(`Objeto privado sem tamanho: ${filename}`)
const range = getRangeRequestInfo({ fileSize, rangeHeader })
if (range.type === 'invalid') return { range, body: null }
@ -93,7 +111,7 @@ const openS3Object = async ({
...(range.type === 'partial' ? { Range: `bytes=${range.rangeStart}-${range.rangeEnd}` } : {}),
}),
)
if (!object.Body) throw new Error(`Objeto de reel sem corpo: ${filename}`)
if (!object.Body) throw new Error(`Objeto privado sem corpo: ${filename}`)
return { range, body: object.Body as unknown as BodyInit }
}
@ -107,12 +125,7 @@ const openLocalObject = async ({
filename: string
rangeHeader: string | null
}): Promise<OpenedObject> => {
const resolvedDir = path.resolve(staticDir || DEFAULT_STATIC_DIR)
const filePath = path.resolve(resolvedDir, filename)
if (!filePath.startsWith(resolvedDir + path.sep)) {
throw new Error('Nome de arquivo de reel inválido.')
}
const filePath = resolveLocalPath(staticDir, filename)
const stats = await stat(filePath)
const range = getRangeRequestInfo({ fileSize: stats.size, rangeHeader })
if (range.type === 'invalid') return { range, body: null }
@ -129,19 +142,19 @@ const openLocalObject = async ({
}
const notFound = (): Response =>
new Response(null, { status: 404, headers: { 'Cache-Control': REEL_MEDIA_CACHE_CONTROL } })
new Response(null, { status: 404, headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL } })
/**
* Streams one artifact. A missing object answers `404`; an unsatisfiable range
* answers `416` (both with the private headers) — never a leaked S3 error.
*/
export const buildReelMediaResponse = async ({
export const buildPrivateMediaResponse = async ({
media,
staticDir,
rangeHeader,
download,
}: {
media: ReelMediaFile
media: PrivateMediaFile
staticDir: string
rangeHeader: string | null
download: boolean
@ -150,14 +163,14 @@ export const buildReelMediaResponse = async ({
if (!filename) return notFound()
try {
const storage = reelMediaStorage()
const storage = privateMediaStorage()
const opened = storage
? await openS3Object({ storage, filename, rangeHeader })
: await openLocalObject({ staticDir, filename, rangeHeader })
return new Response(opened.body, {
status: opened.range.status,
headers: reelMediaHeaders({
headers: privateMediaHeaders({
range: opened.range,
mimeType: media.mimeType,
filename,
@ -171,3 +184,40 @@ export const buildReelMediaResponse = async ({
throw error
}
}
/**
* Copies the stored artifact to `destinationPath` (the transcription job's
* temp input). Streams end to end: a recording of hours never becomes a JS
* Buffer. A missing object raises — the job maps it to `failed`.
*/
export const downloadPrivateMediaToFile = async ({
media,
staticDir,
destinationPath,
}: {
media: PrivateMediaFile
staticDir: string
destinationPath: string
}): Promise<void> => {
const filename = media.filename
if (!filename) throw new Error('Mídia privada sem nome de arquivo.')
const storage = privateMediaStorage()
if (!storage) {
await pipeline(
createReadStream(resolveLocalPath(staticDir, filename)),
createWriteStream(destinationPath),
)
return
}
const object = await storage.client.send(
new GetObjectCommand({ Bucket: storage.bucket, Key: filename }),
)
if (!object.Body) throw new Error(`Objeto privado sem corpo: ${filename}`)
await pipeline(
object.Body as unknown as NodeJS.ReadableStream,
createWriteStream(destinationPath),
)
}

View file

@ -0,0 +1,274 @@
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 {
RECORDING_FAILURE_INTERRUPTED,
RECORDING_MEDIA_SLUG,
type RecordingStep,
} from '@/lib/recording'
import {
buildRecordingAudioFfmpegArgs,
mergeChunkTranscriptions,
RECORDING_AUDIO_CHUNK_SECONDS,
recordingSearchText,
} from '@/lib/recordingTranscription'
import type { Recording } from '@/payload-types'
import {
deepInfraTranscribeSegments,
type TranscribeSegmentsResult,
} from '@/utilities/ai/deepInfraTranscribe'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { messageOf, runFfmpeg } from '@/utilities/media/ffmpeg'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import { downloadPrivateMediaToFile } from '@/utilities/privateMedia/privateMediaResponse'
/**
* C199 — the transcription job: download the private recording, extract/split
* the audio with ffmpeg, transcribe each chunk with timestamps and persist the
* merged transcript (the `ready` state). Runs after the upload response
* (`recordingScheduler`) so no request waits on hours of audio; every failure
* leaves the row `failed` with the file preserved and the retry reuses the row.
*/
/** A recording stopped mid-flight (deploy/restart) is reaped to `failed` after this. */
export const RECORDING_STALE_MS = 60 * 60_000
/** Audio extraction has no duration to scale the timeout from; 30 min is ample. */
const RECORDING_FFMPEG_TIMEOUT_MS = 30 * 60_000
const FAILURE_FALLBACK = 'Falha ao transcrever a gravação.'
const NO_AUDIO_MESSAGE = 'Não foi possível extrair o áudio da gravação.'
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 acervo gate and the pipeline owns its state.
const loadRecordingSystem = (payload: Payload, recordingId: number) =>
payload.findByID({
collection: 'recording',
id: recordingId,
depth: 1,
// Intentional admin bypass: the job reads the row it owns.
overrideAccess: true,
})
const updateRecordingSystem = (
payload: Payload,
recordingId: number,
data: Partial<Recording>,
req?: PayloadTransactionRequest,
) =>
payload.update({
collection: 'recording',
id: recordingId,
data,
// Intentional admin bypass: the pipeline owns the row state.
overrideAccess: true,
...(req ? { req } : {}),
})
/**
* Fails the row and keeps the step it died on: the detail maps the step and the
* known literals into the honest message, while `error` keeps the raw detail
* for the admin.
*/
const failRecording = (
payload: Payload,
recordingId: number,
message: string,
step: RecordingStep | null = 'extracting',
): Promise<unknown> =>
updateRecordingSystem(payload, recordingId, { status: 'failed', step, error: message })
const staticDirOf = (payload: Payload): string => {
const upload = payload.collections[RECORDING_MEDIA_SLUG].config.upload
return upload && typeof upload === 'object' && upload.staticDir
? upload.staticDir
: RECORDING_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 whole pipeline of one recording. Never throws: an orphan job (server
* restart) is covered by the lazy reaper, and any error marks the row `failed`
* so the retry reuses it.
*/
export const runRecordingJob = async (
payload: Payload,
recordingId: number,
transcribe: ChunkTranscriber = deepInfraTranscribeSegments,
): Promise<void> => {
let tempDir: string | null = null
let currentStep: RecordingStep = 'extracting'
const markStep = async (step: RecordingStep): Promise<void> => {
currentStep = step
await updateRecordingSystem(payload, recordingId, { step })
}
try {
const recording = await loadRecordingSystem(payload, recordingId)
if (recording.status !== 'processing') return
const media =
typeof recording.media === 'object' && recording.media !== null ? recording.media : null
if (!media?.filename) {
await failRecording(payload, recordingId, NO_AUDIO_MESSAGE, 'extracting')
return
}
await markStep('extracting')
tempDir = await mkdtemp(join(tmpdir(), 'recording-'))
const inputPath = join(tempDir, 'source')
const outputPattern = join(tempDir, 'chunk-%03d.mp3')
await downloadPrivateMediaToFile({
media,
staticDir: staticDirOf(payload),
destinationPath: inputPath,
})
await runFfmpeg(
buildRecordingAudioFfmpegArgs({ inputPath, outputPattern }),
RECORDING_AUDIO_CHUNK_SECONDS,
NO_AUDIO_MESSAGE,
RECORDING_FFMPEG_TIMEOUT_MS,
)
const chunkFiles = await chunkFilesIn(tempDir)
if (chunkFiles.length === 0) {
await failRecording(payload, recordingId, NO_AUDIO_MESSAGE, 'extracting')
return
}
await markStep('transcribing')
const chunks: {
offsetSeconds: number
segments: { start: number; end: number; text: string }[]
}[] = []
let durationSeconds = 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) {
await failRecording(payload, recordingId, result.error, 'transcribing')
return
}
chunks.push({
offsetSeconds: index * RECORDING_AUDIO_CHUNK_SECONDS,
segments: result.segments,
})
// Only what the provider measured: a guessed duration would make the
// detail label lie.
if (result.durationSeconds !== null) durationSeconds += result.durationSeconds
// Heartbeat: a chunk takes minutes; the updatedAt keeps the reaper away.
await updateRecordingSystem(payload, recordingId, { step: 'transcribing' })
}
const segments = mergeChunkTranscriptions(chunks)
if (segments.length === 0) {
await failRecording(payload, recordingId, FAILURE_FALLBACK, 'transcribing')
return
}
await markStep('saving')
await withPayloadTransaction(payload, async ({ req }) => {
await payload.delete({
collection: 'recordingSegment',
where: { recording: { equals: recordingId } },
req,
// Intentional admin bypass: replacing segments owned by the recording.
overrideAccess: true,
})
for (const segment of segments) {
await payload.create({
collection: 'recordingSegment',
// `searchText` is filled by the collection's own beforeValidate hook.
data: hookFilledCreateData<'recordingSegment'>({
recording: recordingId,
order: segment.order + 1,
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
text: segment.text,
}),
req,
// Intentional admin bypass: the transcript belongs to the recording row.
overrideAccess: true,
})
}
await updateRecordingSystem(
payload,
recordingId,
{
status: 'ready',
step: null,
error: null,
durationSeconds: Math.round(durationSeconds),
searchText: recordingSearchText(segments),
},
req,
)
})
} catch (error) {
await failRecording(
payload,
recordingId,
messageOf(error, FAILURE_FALLBACK),
currentStep,
).catch(() => undefined)
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}
}
/**
* Lazy reaper: a row untouched for `RECORDING_STALE_MS` was lost to a restart
* (there is no queue to ask). A stale `processing` row becomes `failed` with the
* interruption copy; a stale `uploading` row never stored a usable file, so it
* is removed — the same outcome as the upload path's own cleanup. The
* conditional `where` never clobbers a job that just finished — intentional
* admin bypass, like the other system writes above.
*/
export const reapStaleRecording = async (
payload: Payload,
recording: { id: number; status: string; updatedAt: string },
): Promise<boolean> => {
if (recording.status !== 'processing' && recording.status !== 'uploading') return false
const updatedAt = Date.parse(recording.updatedAt)
if (!Number.isFinite(updatedAt) || Date.now() - updatedAt < RECORDING_STALE_MS) return false
if (recording.status === 'uploading') {
const deleted = await payload.delete({
collection: 'recording',
where: { and: [{ id: { equals: recording.id } }, { status: { equals: 'uploading' } }] },
// Intentional admin bypass: the reaper repairs a row the system owns.
overrideAccess: true,
})
return deleted.docs.length > 0
}
const result = await payload.update({
collection: 'recording',
where: { and: [{ id: { equals: recording.id } }, { status: { equals: 'processing' } }] },
data: { status: 'failed', step: null, error: RECORDING_FAILURE_INTERRUPTED },
// Intentional admin bypass: the reaper repairs a row the system owns.
overrideAccess: true,
})
return result.docs.length > 0
}

View file

@ -0,0 +1,16 @@
/**
* C199 — Payload `where` of the uploaded-recordings list: one textual branch
* over the normalized `recording.searchText` (the concatenation of the
* transcript segments). No facets: the Câmara facets do not apply to a
* recording. A recording still processing has no `searchText` and is honestly
* out of the search until the transcript is saved.
*/
import type { Where } from 'payload'
import { normalizeForSearch } from '@/lib/speechSearch'
import type { RecordingListState } from '@/utilities/recordings/recordingListUrl'
export const buildRecordingListWhere = (state: RecordingListState): Where => {
const normalized = normalizeForSearch(state.q ?? '')
return normalized ? { searchText: { like: normalized } } : {}
}

View file

@ -0,0 +1,83 @@
/**
* C199 — source switcher + list URL contract of the uploaded recordings. The
* Câmara acervo keeps its own frozen contract (`speechListUrl`); the recordings
* branch lives on the same `/campanha/comunicacao/acervo` page behind
* `?source=enviadas` and owns its parses/serialization over the shared helpers.
*/
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import {
buildListHref,
firstValue,
normalizedText,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
export const recordingPageSize = 25
/** Which acervo source the page is showing. `camara` is the default. */
export type AcervoSource = 'camara' | 'enviadas'
const ACERVO_SOURCE_PARAM = 'source'
const ACERVO_SOURCE_ENVIADAS = 'enviadas'
const recordingListParamNames = [ACERVO_SOURCE_PARAM, 'q', 'page'] as const
const recordingListParamNameSet = new Set<string>(recordingListParamNames)
export type RecordingListState = {
source: 'enviadas'
page: number
q?: string
}
/** `enviadas` only for the explicit value; anything else is the Câmara default. */
export const parseAcervoSource = (params: RawSearchParams): AcervoSource =>
firstValue(params[ACERVO_SOURCE_PARAM]) === ACERVO_SOURCE_ENVIADAS ? 'enviadas' : 'camara'
export const parseRecordingListParams = (params: RawSearchParams): RecordingListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const rawQ = normalizedText(firstValue(params.q))
const q = rawQ && isContactSearchQueryReady(rawQ) ? rawQ : undefined
return {
source: 'enviadas',
page: rawPage ?? 1,
...(q ? { q } : {}),
}
}
const recordingListSearchParams = (
state: RecordingListState,
page = state.page,
): URLSearchParams => {
const params = new URLSearchParams()
// `source=enviadas` is always serialized — it is what selects the source.
params.set(ACERVO_SOURCE_PARAM, ACERVO_SOURCE_ENVIADAS)
if (state.q) params.set('q', state.q)
if (page > 1) params.set('page', String(page))
return params
}
export const buildRecordingListHref = (state: RecordingListState, page: number): string =>
buildListHref(state, recordingListSearchParams, CAMPAIGN_COMMUNICATION_ACERVO, page)
/** Toggle hrefs of the source switcher (the Câmara side is the bare acervo). */
export const buildAcervoSourceHref = (source: AcervoSource): string =>
source === 'enviadas'
? `${CAMPAIGN_COMMUNICATION_ACERVO}?${ACERVO_SOURCE_PARAM}=${ACERVO_SOURCE_ENVIADAS}`
: CAMPAIGN_COMMUNICATION_ACERVO
export const resolveRecordingListUrl = (
params: RawSearchParams,
totalPages?: number,
): { state: RecordingListState; href: string; redirectHref?: string } =>
resolveListUrl({
params,
paramNameSet: recordingListParamNameSet,
parse: parseRecordingListParams,
buildSearchParams: recordingListSearchParams,
basePath: CAMPAIGN_COMMUNICATION_ACERVO,
totalPages,
})

View file

@ -0,0 +1,233 @@
import 'server-only'
import type { Payload } from 'payload'
import { RECORDING_NOT_FOUND_MESSAGE } from '@/lib/schemas/recording'
import { normalizeForSearch } from '@/lib/speechSearch'
import type { CampaignUser } from '@/payload-types'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
import { buildRecordingListWhere } from '@/utilities/recordings/recordingListFilters'
import {
recordingPageSize,
resolveRecordingListUrl,
type RecordingListState,
} from '@/utilities/recordings/recordingListUrl'
import {
toRecordingDetailViewModel,
toRecordingListItemViewModel,
type RecordingDetailViewModel,
type RecordingListItemViewModel,
type RecordingSegmentRecord,
} from '@/utilities/recordings/recordingViewModels'
type RecordingListSearchParams = Record<string, string | string[] | undefined>
export const RecordingNotFoundError = createEntityNotFoundError(
'Recording',
RECORDING_NOT_FOUND_MESSAGE,
)
const recordingListSelect = {
title: true,
status: true,
step: true,
recordedAt: true,
durationSeconds: true,
} as const
const recordingDetailSelect = {
...recordingListSelect,
error: true,
} as const
const segmentSelect = {
startSeconds: true,
endSeconds: true,
text: true,
} as const
const toSegmentRecord = (segment: {
startSeconds: number
endSeconds: number
text: string
}): RecordingSegmentRecord => ({
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
text: segment.text,
})
/**
* The excerpt segment of each listed recording: ONE matching segment per row
* (the search runs `LIKE` on the joined `recording.searchText`, so a page of
* hour-long recordings never pays for all of its segments). The rows whose
* query only matches across the junction of two segments fall back together in
* a single batched query, so a result never renders without an excerpt.
*/
const loadMatchedSegments = async (
payload: Payload,
user: CampaignUser,
recordingIds: readonly number[],
q: string,
): Promise<Map<number, RecordingSegmentRecord[]>> => {
const byRecording = new Map<number, RecordingSegmentRecord[]>()
const normalized = normalizeForSearch(q)
if (!normalized || recordingIds.length === 0) return byRecording
await Promise.all(
recordingIds.map(async (recordingId) => {
const match = await payload.find({
collection: 'recordingSegment',
where: {
and: [{ recording: { equals: recordingId } }, { searchText: { like: normalized } }],
},
depth: 0,
limit: 1,
pagination: false,
sort: 'order',
select: segmentSelect,
user,
overrideAccess: false,
})
if (match.docs[0]) byRecording.set(recordingId, [toSegmentRecord(match.docs[0])])
}),
)
const misses = recordingIds.filter((recordingId) => !byRecording.has(recordingId))
if (misses.length === 0) return byRecording
const fallback = await payload.find({
collection: 'recordingSegment',
where: { recording: { in: misses } },
depth: 0,
limit: 0,
pagination: false,
sort: 'order',
// `recording` only here: the viewer needs which row each fallback belongs to.
select: { ...segmentSelect, recording: true },
user,
overrideAccess: false,
})
for (const segment of fallback.docs) {
const recordingId =
typeof segment.recording === 'number' ? segment.recording : segment.recording.id
if (!recordingId || byRecording.has(recordingId)) continue
byRecording.set(recordingId, [toSegmentRecord(segment)])
}
return byRecording
}
export type RecordingsPageData = {
rows: RecordingListItemViewModel[]
state: RecordingListState
redirectHref?: string
totalDocs: number
totalPages: number
}
export const loadRecordingsPageData = async (
payload: Payload,
user: CampaignUser,
searchParams: Promise<RecordingListSearchParams> | RecordingListSearchParams,
): Promise<RecordingsPageData> => {
const rawSearchParams = await searchParams
const canonicalUrl = resolveRecordingListUrl(rawSearchParams)
const state = canonicalUrl.state
const result = await payload.find({
collection: 'recording',
depth: 0,
limit: recordingPageSize,
page: state.page,
sort: '-createdAt',
where: buildRecordingListWhere(state),
select: recordingListSelect,
user,
overrideAccess: false,
})
const resolvedUrl = resolveRecordingListUrl(rawSearchParams, result.totalPages)
const recordings = result.docs
const segmentsByRecording = state.q
? await loadMatchedSegments(
payload,
user,
recordings.map((recording) => recording.id),
state.q,
)
: new Map<number, RecordingSegmentRecord[]>()
return {
rows: recordings.map((recording) =>
toRecordingListItemViewModel({
recording,
matchedSegments: segmentsByRecording.get(recording.id) ?? [],
query: state.q,
}),
),
state: resolvedUrl.state,
redirectHref: resolvedUrl.redirectHref ?? canonicalUrl.redirectHref,
totalDocs: result.totalDocs,
totalPages: result.totalPages,
}
}
/**
* Title-only read for `generateMetadata`: never loads the transcript of an
* hours-long recording just to title the tab.
*/
export const loadRecordingTitleForActor = async (
payload: Payload,
user: CampaignUser,
recordingId: number,
): Promise<string | null> => {
const result = await payload.find({
collection: 'recording',
where: { id: { equals: recordingId } },
depth: 0,
limit: 1,
pagination: false,
select: { title: true },
user,
overrideAccess: false,
})
return result.docs[0]?.title ?? null
}
export const loadRecordingDetailPageData = async (
payload: Payload,
user: CampaignUser,
recordingId: number,
query?: string,
): Promise<RecordingDetailViewModel> => {
const result = await payload.find({
collection: 'recording',
where: { id: { equals: recordingId } },
depth: 0,
limit: 1,
pagination: false,
select: recordingDetailSelect,
user,
overrideAccess: false,
})
const recording = result.docs[0]
if (!recording) throw new RecordingNotFoundError()
const segments = await payload.find({
collection: 'recordingSegment',
where: { recording: { equals: recordingId } },
depth: 0,
limit: 0,
pagination: false,
sort: 'order',
select: segmentSelect,
user,
overrideAccess: false,
})
return toRecordingDetailViewModel({
recording,
segments: segments.docs.map(toSegmentRecord),
query,
})
}

View file

@ -0,0 +1,20 @@
import 'server-only'
import config from '@payload-config'
import { after } from 'next/server'
import { getPayload } from 'payload'
import { runRecordingJob } from '@/utilities/recordings/recordingJob'
/**
* C199 — starts the transcription after the upload/retry response has been sent
* (`after` from `next/server`), so the request never waits on ffmpeg or hours
* of ASR. Kept apart from the job so `recordingJob` stays importable in a plain
* Node test without the request lifecycle.
*/
export const startRecordingJobInBackground = (recordingId: number): void => {
after(async () => {
const payload = await getPayload({ config })
await runRecordingJob(payload, recordingId)
})
}

View file

@ -0,0 +1,144 @@
import 'server-only'
import { createWriteStream } from 'node:fs'
import { mkdtemp, 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 {
RECORDING_MAX_BYTES,
RECORDING_MEDIA_SLUG,
recordingUploadTooLargeMessage,
sanitizeRecordingFilename,
} from '@/lib/recording'
import type { RecordingUploadMetadata } from '@/lib/schemas/recording'
import type { CampaignUser } from '@/payload-types'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { startRecordingJobInBackground } from '@/utilities/recordings/recordingScheduler'
/**
* C199 — the upload half of the recordings flow: create the visible row, stream
* the raw request body to a temp file with the size ceiling enforced, then store
* the private media and hand the row to the transcription job. The route owns
* the origin/session/role gate; this module owns the write path.
*/
/** Shared literal: the route allowlists it and the stream guard throws it. */
export const RECORDING_BODY_MISSING_MESSAGE = 'A requisição não trouxe o arquivo da gravação.'
const toUploadTooLarge = (): Error => new Error(recordingUploadTooLargeMessage)
/**
* 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
}
export const receiveRecordingUpload = async ({
payload,
actor,
metadata,
body,
contentLength,
startJob = startRecordingJobInBackground,
}: {
payload: Payload
actor: CampaignUser
metadata: RecordingUploadMetadata
body: ReadableStream<Uint8Array> | null
contentLength: number | null
/** Injectable for tests; the route uses the `after()`-based default. */
startJob?: (recordingId: number) => void
}): Promise<{ id: number }> => {
if (contentLength !== null && contentLength > RECORDING_MAX_BYTES) throw toUploadTooLarge()
if (!body) throw new Error(RECORDING_BODY_MISSING_MESSAGE)
const recording = await payload.create({
collection: 'recording',
data: {
title: metadata.title,
status: 'uploading',
...(metadata.recordedAt ? { recordedAt: metadata.recordedAt } : {}),
},
depth: 0,
user: actor,
overrideAccess: false,
})
let tempDir: string | null = null
try {
tempDir = await mkdtemp(join(tmpdir(), 'recording-upload-'))
const uploadName = sanitizeRecordingFilename(metadata.filename)
const tempPath = join(tempDir, uploadName)
await streamBodyToFile({ body, destination: tempPath, maxBytes: RECORDING_MAX_BYTES })
await withPayloadTransaction(payload, async ({ req }) => {
const media = await payload.create({
collection: RECORDING_MEDIA_SLUG,
data: { alt: metadata.title },
filePath: tempPath,
depth: 0,
user: actor,
overrideAccess: false,
req,
})
await payload.update({
collection: 'recording',
id: recording.id,
data: { status: 'processing', step: 'extracting', media: media.id },
depth: 0,
user: actor,
overrideAccess: false,
req,
})
})
startJob(recording.id)
return { id: recording.id }
} catch (error) {
// The upload never completed: the row must not linger as a phantom
// "Enviando". The media (when it exists) is unreachable without the row.
await payload
.delete({
collection: 'recording',
id: recording.id,
// Intentional admin bypass: cleanup of the row this module just created.
overrideAccess: true,
})
.catch(() => undefined)
throw error
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}
}

View file

@ -0,0 +1,133 @@
/**
* C199 — view models of the uploaded recordings. Pure: the loader hands raw
* Payload rows and the view model decides labels, the matching excerpt and the
* links the list/detail render.
*/
import {
recordingFailureMessage,
toRecordingViewModel,
type RecordingViewModel,
} from '@/lib/recording'
import { formatSpeechClock } from '@/lib/speechClock'
import {
buildHighlightedExcerpt,
pickMatchingSegment,
splitHighlightedParts,
type SpeechHighlightedExcerpt,
type SpeechHighlightPart,
} from '@/lib/speechHighlight'
/** The segment shape the recording detail transcript renders. */
export type RecordingSegmentRecord = {
startSeconds: number
endSeconds: number
text: string
}
type RecordingBaseRecord = {
id: number
title?: string | null
status: string
step?: string | null
recordedAt?: string | null
durationSeconds?: number | null
}
export type RecordingListRecord = RecordingBaseRecord
export type RecordingDetailRecord = RecordingBaseRecord & {
/** Raw internal cause of a failure; never handed to the client as-is. */
error?: string | null
}
export type RecordingListItemViewModel = RecordingViewModel & {
excerpt: SpeechHighlightedExcerpt | null
/**
* Detail link that seeks the player to the matching segment when the search
* found one (`?t=`), carrying the term for transcript highlighting (`?q=`).
*/
watchHref: string
}
export type RecordingDetailSegmentViewModel = {
startSeconds: number
endSeconds: number
startLabel: string
parts: SpeechHighlightPart[]
}
export type RecordingDetailViewModel = RecordingViewModel & {
/** Honest cause of a failure; null unless `status === 'failed'`. */
failureMessage: string | null
segments: RecordingDetailSegmentViewModel[]
}
const withSeek = (href: string, segment: RecordingSegmentRecord | undefined, query?: string) => {
const params = new URLSearchParams()
if (segment) params.set('t', String(Math.max(0, Math.floor(segment.startSeconds))))
const q = query?.trim()
if (q) params.set('q', q)
const queryString = params.toString()
return queryString ? `${href}?${queryString}` : href
}
/**
* List item of one recording. The excerpt only exists for a `ready` row and a
* search term; the status cards of the other three states carry their own copy
* (design scene 2) and no transcript claim.
*/
export const toRecordingListItemViewModel = ({
recording,
matchedSegments,
query,
}: {
recording: RecordingListRecord
/** Segments that matched the query (or the row's first one as fallback). */
matchedSegments: readonly RecordingSegmentRecord[]
query?: string
}): RecordingListItemViewModel => {
const base = toRecordingViewModel(recording)
const isReady = base.status === 'ready'
const q = query?.trim()
const segment = isReady && q ? pickMatchingSegment(matchedSegments, q) : undefined
const excerpt = segment ? buildHighlightedExcerpt(segment.text, q ?? '') : null
return {
...base,
excerpt,
watchHref: withSeek(base.detailHref, segment, q),
}
}
/**
* Detail view of one recording: the base view plus the failure message and the
* clickable transcript segments (highlighted by the `?q=` of the search).
*/
export const toRecordingDetailViewModel = ({
recording,
segments,
query,
}: {
recording: RecordingDetailRecord
segments: readonly RecordingSegmentRecord[]
query?: string
}): RecordingDetailViewModel => {
const base = toRecordingViewModel(recording)
const isFailed = base.status === 'failed'
return {
...base,
failureMessage: isFailed
? recordingFailureMessage({
error: recording.error,
step: base.step,
})
: null,
segments: segments.map((segment) => ({
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
startLabel: formatSpeechClock(segment.startSeconds),
parts: splitHighlightedParts(segment.text, query ?? ''),
})),
}
}

View file

@ -19,11 +19,12 @@ import {
} from '@/lib/speechCut'
import { speechVodCoordinates } from '@/lib/speechVod'
import type { SpeechCut } from '@/payload-types'
import { messageOf, runFfmpeg } from '@/utilities/media/ffmpeg'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import { downloadSource, messageOf, runFfmpeg } from '@/utilities/speech/speechMediaPipeline'
import { downloadSource } from '@/utilities/speech/speechMediaPipeline'
import { resolveSpeechVod, SPEECH_VOD_CUT_POLICY } from '@/utilities/speech/speechVodResolver'
/**

View file

@ -1,6 +1,5 @@
import 'server-only'
import { execFile } from 'node:child_process'
import { createWriteStream } from 'node:fs'
import { Readable, Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
@ -9,25 +8,15 @@ import { CAMARA_USER_AGENT } from '@/lib/speechVod'
/**
* C182 — the Câmara media pipeline shared by the cut job (C167) and the acervo
* frame job (C182): download one resolved VOD to a temp file and run ffmpeg on
* it. Extracted from `speechCutJob` so both jobs own a single implementation;
* the temp dir lifecycle and the failure copy stay with each job.
* frame job (C182): download one resolved VOD to a temp file. ffmpeg execution
* is owned by `utilities/media/ffmpeg` (C199 extracted it for the recording
* job); the temp dir lifecycle and the failure copy stay with each job.
*/
/** `FFMPEG_PATH` lets a test/runtime point at a fake binary; the image ships `ffmpeg`. */
const ffmpegBinary = (): string => process.env.FFMPEG_PATH?.trim() || 'ffmpeg'
/** Defensive ceiling for the source download (the VOD of a speech is minutes long). */
const MAX_SOURCE_BYTES = 2 * 1024 * 1024 * 1024
const SOURCE_DOWNLOAD_TIMEOUT_MS = 180_000
const FFMPEG_MIN_TIMEOUT_MS = 30_000
const FFMPEG_MAX_TIMEOUT_MS = 300_000
const FFMPEG_MAX_BUFFER_BYTES = 8 * 1024 * 1024
/** Last-resort copy when an error carries no message; each job names its own. */
export const messageOf = (error: unknown, fallback: string): string =>
error instanceof Error && error.message !== '' ? error.message : fallback
/**
* Streams the resolved VOD to a temp file. A `text/html` body is the CDN's
@ -68,31 +57,3 @@ export const downloadSource = async (url: string, destination: string): Promise<
createWriteStream(destination),
)
}
/** Runs ffmpeg with a timeout scaled by the source duration (clamped to a floor/ceiling). */
export const runFfmpeg = (
args: string[],
durationSeconds: number,
fallbackMessage = 'Falha ao processar o vídeo.',
): Promise<void> =>
new Promise((resolve, reject) => {
const timeout = Math.min(
FFMPEG_MAX_TIMEOUT_MS,
Math.max(FFMPEG_MIN_TIMEOUT_MS, durationSeconds * 8_000),
)
execFile(
ffmpegBinary(),
args,
{ timeout, maxBuffer: FFMPEG_MAX_BUFFER_BYTES },
(error, _stdout, stderr) => {
if (!error) {
resolve()
return
}
const detail = String(stderr ?? '')
.trim()
.slice(-400)
reject(new Error(`${messageOf(error, fallbackMessage)}${detail ? ` — ${detail}` : ''}`))
},
)
})

View file

@ -12,12 +12,13 @@ import {
speechPosterTarget,
} from '@/lib/speechPoster'
import type { Media } from '@/payload-types'
import { messageOf, runFfmpeg } from '@/utilities/media/ffmpeg'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { downloadSource, messageOf, runFfmpeg } from '@/utilities/speech/speechMediaPipeline'
import { downloadSource } from '@/utilities/speech/speechMediaPipeline'
import { resolveSpeechVod, SPEECH_VOD_PLAYER_POLICY } from '@/utilities/speech/speechVodResolver'
/**

View file

@ -10,7 +10,7 @@ import type { SpeechExcerptSegment } from '@/lib/speechExcerpt'
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
import {
buildHighlightedExcerpt,
matchesSearchTerms,
pickMatchingSegment,
speechSearchTerms,
splitHighlightedParts,
type SpeechHighlightedExcerpt,
@ -180,21 +180,6 @@ const topicViewModels = (speech: SpeechListRecord) =>
const scopeViewModels = (speech: SpeechListRecord) =>
(speech.scopes ?? []).map((value) => ({ value, label: speechScopeLabels[value] }))
const pickMatchingSegment = (
segments: readonly SpeechSegmentRecord[],
query: string | undefined,
): SpeechSegmentRecord | undefined => {
const q = query?.trim()
if (!q || segments.length === 0) return undefined
const allTerms = segments.find((segment) => matchesSearchTerms(segment.text, q))
if (allTerms) return allTerms
const terms = speechSearchTerms(q)
return segments.find((segment) => {
const normalized = normalizeForSearch(segment.text)
return terms.some((term) => normalized.includes(term))
})
}
/**
* C192 — the first expanded theme term that actually surfaced this speech. The
* gate is the `speechMatchesSearchTerm` mirror (the same predicate the `where`
@ -243,13 +228,6 @@ export const buildWatchHref = (
return `${CAMPAIGN_COMMUNICATION_ACERVO}/${speechId}${queryString ? `?${queryString}` : ''}`
}
/** Seek offset from the detail URL (`?t=`), non-negative seconds or null. */
export const parseSpeechSeekSeconds = (raw: string | undefined): number | null => {
if (raw === undefined) return null
const seconds = Number(raw)
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null
}
type SpeechCuts = readonly SpeechCutSummaryViewModel[]
const thumbnailUrlOf = (speech: SpeechListRecord): string | null =>

View file

@ -1,6 +1,8 @@
import { request as playwrightRequest } from '@playwright/test'
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
import { normalizeForSearch } from '../../src/lib/speechSearch.js'
import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
import type { CampaignE2EOwnership } from './fixtures/campaignE2EFixtures.js'
import { assertCampaignRedirect, expect, rendered, test } from './fixtures/campaignHttpTest.js'
@ -515,4 +517,173 @@ test.describe('communication vertical (C154/C162)', () => {
expect(denied.status()).toBe(404)
})
})
test.describe('uploaded recordings (C199)', () => {
const createRecording = async (
campaign: { fixtures: CampaignE2EOwnership },
input: { marker: string; status?: 'processing' | 'ready' | 'failed'; withSegments?: boolean },
) => {
const bytes = Buffer.from(`recording-${input.marker}`)
const media = await campaign.fixtures.payload.create({
collection: 'recordingMedia',
data: { alt: `Arquivo ${input.marker}` },
file: {
data: bytes,
mimetype: 'video/mp4',
name: `${input.marker}.mp4`,
size: bytes.length,
},
})
const recording = await campaign.fixtures.payload.create({
collection: 'recording',
data: {
title: `Gravação ${input.marker}`,
status: input.status ?? 'ready',
recordedAt: '2026-09-01T00:00:00.000Z',
media: media.id,
},
depth: 0,
})
if (input.withSegments ?? true) {
await campaign.fixtures.payload.create({
collection: 'recordingSegment',
data: hookFilledCreateData<'recordingSegment'>({
recording: recording.id,
order: 1,
startSeconds: 12,
endSeconds: 20,
text: `A plenária discutiu a merenda ${input.marker} com a comunidade.`,
}),
depth: 0,
})
await campaign.fixtures.payload.update({
collection: 'recording',
id: recording.id,
data: {
searchText: normalizeForSearch(
`A plenária discutiu a merenda ${input.marker} com a comunidade.`,
),
},
depth: 0,
})
}
return { recording, media, bytes }
}
test('the recordings source renders the row, the excerpt and the switcher', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('gravacao')
const { recording } = await createRecording(campaign, { marker })
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const response = await request.get(`/campanha/comunicacao/acervo?source=enviadas&q=${marker}`)
expect(response.status()).toBe(200)
const html = rendered(await response.text())
expect(html).toContain('Gravações enviadas')
expect(html).toContain('aria-current="page"')
expect(html).toContain(`Gravação ${marker}`)
expect(html).toContain('Pronto')
expect(html).toContain('<mark')
expect(html).toContain(`?t=12`)
expect(html).toContain(`/campanha/comunicacao/acervo/gravacoes/${recording.id}`)
const miss = await request.get('/campanha/comunicacao/acervo?source=enviadas&q=zzzznada')
expect(rendered(await miss.text())).toContain('Nenhuma gravação encontrada para')
// The alias segment resolves to the source instead of being swallowed by
// the speech detail regex (the route gate streams, so the redirect may
// arrive as a meta tag — `assertCampaignRedirect` accepts both).
await assertCampaignRedirect(
request,
'/campanha/comunicacao/acervo/gravacoes',
'/campanha/comunicacao/acervo?source=enviadas',
)
})
test('the detail renders the private player, the clickable transcript and the download', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('gravacaodetalhe')
const { recording } = await createRecording(campaign, { marker })
const user = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(user, user.password)
const response = await request.get(
`/campanha/comunicacao/acervo/gravacoes/${recording.id}?t=12&q=${marker}`,
)
expect(response.status()).toBe(200)
const html = rendered(await response.text())
expect(html).toContain(`Gravação ${marker}`)
expect(html).toContain(`/campanha/comunicacao/acervo/gravacoes/${recording.id}/arquivo`)
expect(html).toContain('data-start-seconds="12"')
expect(html).toContain('<mark')
expect(html).toContain('Baixar')
expect(html).toContain('Apagar')
expect(html).toContain('Voltar ao acervo')
})
test('the private file answers only the acervo roles, with range and download', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('gravacaofile')
const { recording, bytes } = await createRecording(campaign, { marker })
const communicator = await campaign.fixtures.createCampaignUser('communicator')
const communicatorRequest = await campaignRequest(communicator, communicator.password)
const full = await communicatorRequest.get(
`/campanha/comunicacao/acervo/gravacoes/${recording.id}/arquivo`,
)
expect(full.status()).toBe(200)
expect(full.headers()['content-type']).toBe('video/mp4')
expect(full.headers()['cache-control']).toBe('private, no-store')
expect(Buffer.from(await full.body())).toEqual(bytes)
const partial = await communicatorRequest.get(
`/campanha/comunicacao/acervo/gravacoes/${recording.id}/arquivo`,
{ headers: { Range: 'bytes=0-3' } },
)
expect(partial.status()).toBe(206)
const download = await communicatorRequest.get(
`/campanha/comunicacao/acervo/gravacoes/${recording.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)
const denied = await advisorRequest.get(
`/campanha/comunicacao/acervo/gravacoes/${recording.id}/arquivo`,
)
expect(denied.status()).toBe(404)
})
test('the upload route refuses 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/acervo/gravacoes/enviar?title=Teste&filename=t.mp4'
const crossOrigin = await request.post(uploadUrl, {
headers: { Origin: 'https://evil.example' },
})
expect(crossOrigin.status()).toBe(403)
const advisor = await campaign.fixtures.createCampaignUser('advisor')
const advisorRequest = await campaignRequest(advisor, advisor.password)
const denied = await advisorRequest.post(uploadUrl)
expect(denied.status()).toBe(403)
})
})
})

View file

@ -1,9 +1,12 @@
#!/usr/bin/env node
/**
* C167 test double for ffmpeg: copies the `-i` input to the output path so the
* cut pipeline can run without a real encoder (the developer machine has none).
* `FAKE_FFMPEG_FAIL=1` makes it exit 1; `FAKE_FFMPEG_LOG=<path>` records argv.
* The real binary is exercised by the `skipIf(!hasFfmpeg())` block (CI).
* C167/C199 test double for ffmpeg: copies the `-i` input to the output path so
* the cut/recording pipelines can run without a real encoder (the developer
* machine has none). A `%0Nd` output pattern (C199's `-f segment`) produces
* numbered copies instead of one literal file, mirroring what the segmented
* command writes. `FAKE_FFMPEG_FAIL=1` makes it exit 1; `FAKE_FFMPEG_LOG=<path>`
* records argv. The real binary is exercised by the `skipIf(!hasFfmpeg())`
* block (CI).
*/
import { copyFileSync, writeFileSync } from 'node:fs'
import { argv, env, exit } from 'node:process'
@ -28,4 +31,15 @@ if (!input || !output) {
exit(2)
}
const segmentPattern = /%0?(\d+)d/.exec(output)
if (segmentPattern) {
const width = Number(segmentPattern[1])
const count = Number(env.FAKE_FFMPEG_SEGMENTS ?? '2')
for (let index = 0; index < count; index += 1) {
const name = output.replace(segmentPattern[0], String(index).padStart(width, '0'))
copyFileSync(input, name)
}
exit(0)
}
copyFileSync(input, output)

View file

@ -0,0 +1,558 @@
// @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/acervo/gravacoes/[id]/arquivo/route'
import {
deleteRecordingForActor,
getRecordingStatusesForActor,
retryRecordingForActor,
} from '@/app/(campaign)/campanha/actions/recording'
import { RECORDING_MAX_BYTES } from '@/lib/recording'
import { normalizeForSearch } from '@/lib/speechSearch'
import type { CampaignUser, RecordingMedia } from '@/payload-types'
import config from '@/payload.config'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import {
reapStaleRecording,
RECORDING_STALE_MS,
runRecordingJob,
} from '@/utilities/recordings/recordingJob'
import { loadRecordingsPageData } from '@/utilities/recordings/recordingPageData'
import { receiveRecordingUpload } from '@/utilities/recordings/recordingUpload'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
const FAKE_FFMPEG = fileURLToPath(new URL('../fixtures/fake-ffmpeg.mjs', import.meta.url))
const VIDEO_BYTES = Buffer.from('fixture-recording-bytes')
let payload: Payload
const createdRecordingIds = 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<RecordingMedia> => {
const media = await payload.create({
collection: 'recordingMedia',
data: { alt: `Arquivo ${name}` },
file: { data, mimetype, name, size: data.length },
overrideAccess: true,
})
createdMediaIds.add(media.id)
return media
}
const createRecording = async ({
status = 'ready',
title = 'Plenária da Comissão',
segments = [],
withMedia = true,
}: {
status?: 'uploading' | 'processing' | 'ready' | 'failed'
title?: string
segments?: { startSeconds: number; endSeconds: number; text: string }[]
withMedia?: boolean
} = {}) => {
const media = withMedia ? await createMedia(`${title}.mp4`) : null
const recording = await payload.create({
collection: 'recording',
data: {
title,
status,
...(media ? { media: media.id } : {}),
},
overrideAccess: true,
})
createdRecordingIds.add(recording.id)
for (const [index, segment] of segments.entries()) {
await payload.create({
collection: 'recordingSegment',
data: hookFilledCreateData<'recordingSegment'>({
recording: recording.id,
order: index + 1,
startSeconds: segment.startSeconds,
endSeconds: segment.endSeconds,
text: segment.text,
}),
overrideAccess: true,
})
}
if (segments.length > 0) {
await payload.update({
collection: 'recording',
id: recording.id,
data: { searchText: normalizeForSearch(segments.map((segment) => segment.text).join(' ')) },
overrideAccess: true,
})
}
return { recording, media }
}
const callFileRoute = async ({
recordingId,
user,
range,
download,
}: {
recordingId: number
user?: CampaignUser | null
range?: string
download?: boolean
}): Promise<Response> => {
getCampaignUserMock.mockResolvedValue(user ?? null)
const request = new Request(
`http://localhost/campanha/comunicacao/acervo/gravacoes/${recordingId}/arquivo${download ? '?download=1' : ''}`,
{ headers: range ? { range } : undefined },
)
return GET(request, { params: Promise.resolve({ id: String(recordingId) }) })
}
describe('uploaded recordings (C199)', () => {
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 createdRecordingIds) {
await payload
.delete({ collection: 'recording', id, overrideAccess: true })
.catch(() => undefined)
}
for (const id of createdMediaIds) {
await payload
.delete({ collection: 'recordingMedia', id, overrideAccess: true })
.catch(() => undefined)
}
})
it('reads recordings only with the communication roles', async () => {
const { recording } = await createRecording()
const readAs = (user?: CampaignUser) =>
payload.find({
collection: 'recording',
where: { id: { equals: recording.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('stamps the actor on the create path', async () => {
const recording = await payload.create({
collection: 'recording',
data: { title: 'Plenária com carimbo', status: 'uploading' },
depth: 0,
user: communicator,
overrideAccess: false,
})
createdRecordingIds.add(recording.id)
expect(recording.createdBy).toBe(communicator.id)
})
it('serves the file once the upload finished and hides it while uploading', async () => {
const ready = await createRecording({ status: 'ready' })
const processing = await createRecording({ status: 'processing' })
const failed = await createRecording({ status: 'failed' })
const uploading = await createRecording({ status: 'uploading', withMedia: false })
for (const recording of [ready.recording, processing.recording, failed.recording]) {
const response = await callFileRoute({ recordingId: recording.id, user: communicator })
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('video/mp4')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(Buffer.from(await response.arrayBuffer())).toEqual(VIDEO_BYTES)
}
expect(
(await callFileRoute({ recordingId: uploading.recording.id, user: communicator })).status,
).toBe(404)
})
it('answers a byte range with 206 and forces download with ?download=1', async () => {
const { recording } = await createRecording()
const partial = await callFileRoute({
recordingId: recording.id,
user: coordinator,
range: 'bytes=0-3',
})
expect(partial.status).toBe(206)
expect(partial.headers.get('Content-Range')).toBe(`bytes 0-3/${VIDEO_BYTES.length}`)
expect(Buffer.from(await partial.arrayBuffer())).toEqual(VIDEO_BYTES.subarray(0, 4))
const download = await callFileRoute({
recordingId: recording.id,
user: coordinator,
download: true,
})
expect(download.headers.get('Content-Disposition')).toContain('attachment')
})
it('denies anonymous, advisor and leader with a silent 404', async () => {
const { recording } = await createRecording()
for (const user of [undefined, advisor, leader]) {
expect((await callFileRoute({ recordingId: recording.id, user })).status).toBe(404)
}
})
it('deletes the transcript in cascade with the recording', async () => {
const { recording } = await createRecording({
segments: [{ startSeconds: 0, endSeconds: 4, text: 'Primeiro trecho' }],
})
await payload.delete({ collection: 'recording', id: recording.id, overrideAccess: true })
createdRecordingIds.delete(recording.id)
const segments = await payload.find({
collection: 'recordingSegment',
where: { recording: { equals: recording.id } },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
expect(segments.docs).toHaveLength(0)
})
it('streams an upload to the private media and hands the row to the job', async () => {
const bytes = Buffer.from('raw-body-upload')
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
})
const scheduled: number[] = []
const { id } = await receiveRecordingUpload({
payload,
actor: communicator,
metadata: {
title: 'Giro pelo interior',
filename: 'giro interior.m4v',
recordedAt: undefined,
},
body,
contentLength: bytes.length,
startJob: (recordingId) => scheduled.push(recordingId),
})
createdRecordingIds.add(id)
const recording = await payload.findByID({
collection: 'recording',
id,
depth: 1,
overrideAccess: true,
})
expect(recording.status).toBe('processing')
expect(recording.step).toBe('extracting')
const createdBy =
typeof recording.createdBy === 'number' ? recording.createdBy : recording.createdBy?.id
expect(createdBy).toBe(communicator.id)
expect(scheduled).toEqual([id])
const media = recording.media
expect(media && typeof media === 'object').toBe(true)
if (media && typeof media === 'object') {
createdMediaIds.add(media.id)
expect(media.filename).toContain('giro_interior')
}
const response = await callFileRoute({ recordingId: id, user: communicator })
expect(response.status).toBe(200)
expect(Buffer.from(await response.arrayBuffer())).toEqual(bytes)
})
it('refuses an upload above the ceiling without leaving a phantom row', async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(Buffer.alloc(8))
controller.close()
},
})
await expect(
receiveRecordingUpload({
payload,
actor: communicator,
metadata: { title: 'Grande demais', filename: 'grande.mp4', recordedAt: undefined },
body,
contentLength: RECORDING_MAX_BYTES + 1,
startJob: () => undefined,
}),
).rejects.toThrow('O arquivo excede o limite de 4 GB.')
const rows = await payload.find({
collection: 'recording',
where: { title: { equals: 'Grande demais' } },
overrideAccess: true,
})
expect(rows.docs).toHaveLength(0)
})
it('runs the transcription job end to end with merged timestamps', async () => {
const { recording } = await createRecording({
status: 'processing',
title: 'Plenária de horas',
})
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
await runRecordingJob(payload, recording.id, async () => ({
ok: true,
segments: [
{ start: 1, end: 4, text: 'Bom dia a todas' },
{ start: 5, end: 9, text: 'e a todos' },
],
durationSeconds: 1_200,
}))
} finally {
restore()
}
const updated = await payload.findByID({
collection: 'recording',
id: recording.id,
depth: 0,
overrideAccess: true,
})
expect(updated.status).toBe('ready')
expect(updated.step).toBeNull()
expect(updated.error).toBeNull()
// The fake ffmpeg writes two chunks and the injected transcriber answers the
// same segments for both, so the transcript carries them twice (chunk 2 at
// the 20-minute offset).
expect(updated.durationSeconds).toBe(2_400)
expect(updated.searchText).toBe('bom dia a todas e a todos bom dia a todas e a todos')
const segments = await payload.find({
collection: 'recordingSegment',
where: { recording: { equals: recording.id } },
depth: 0,
limit: 0,
pagination: false,
sort: 'order',
overrideAccess: true,
})
expect(segments.docs).toHaveLength(4)
expect(segments.docs.map((segment) => segment.startSeconds)).toEqual([1, 5, 1201, 1205])
expect(segments.docs[0]?.searchText).toBe('bom dia a todas')
})
it('marks a provider failure as failed, preserves the file and retries', async () => {
const { recording } = await createRecording({ status: 'processing', title: 'Debate' })
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
try {
await runRecordingJob(payload, recording.id, async () => ({
ok: false,
error: 'Não foi possível transcrever a gravação.',
status: 502,
}))
} finally {
restore()
}
const failed = await payload.findByID({
collection: 'recording',
id: recording.id,
depth: 1,
overrideAccess: true,
})
expect(failed.status).toBe('failed')
expect(failed.step).toBe('transcribing')
expect(failed.error).toBe('Não foi possível transcrever a gravação.')
expect(failed.media && typeof failed.media === 'object').toBe(true)
getCampaignUserMock.mockResolvedValue(communicator)
const retried = await retryRecordingForActor({ recordingId: recording.id })
expect(retried.status).toBe('processing')
await expect(
retryRecordingForActor({ recordingId: (await createRecording()).recording.id }),
).rejects.toThrow('Só é possível reprocessar uma gravação que falhou.')
})
it('reaps a processing row abandoned by a restart', async () => {
const { recording } = await createRecording({ status: 'processing', title: 'Abandonada' })
const reaped = await reapStaleRecording(payload, {
id: recording.id,
status: 'processing',
updatedAt: new Date(Date.now() - RECORDING_STALE_MS - 60_000).toISOString(),
})
expect(reaped).toBe(true)
const updated = await payload.findByID({
collection: 'recording',
id: recording.id,
depth: 0,
overrideAccess: true,
})
expect(updated.status).toBe('failed')
expect(updated.error).toBe('O processamento foi interrompido antes de terminar.')
// A fresh row is never reaped.
const fresh = await createRecording({ status: 'processing' })
expect(
await reapStaleRecording(payload, {
id: fresh.recording.id,
status: 'processing',
updatedAt: new Date().toISOString(),
}),
).toBe(false)
// An `uploading` row abandoned mid-stream has no usable file: reaped away.
const orphan = await createRecording({
status: 'uploading',
title: 'Upload interrompido',
withMedia: false,
})
expect(
await reapStaleRecording(payload, {
id: orphan.recording.id,
status: 'uploading',
updatedAt: new Date(Date.now() - RECORDING_STALE_MS - 60_000).toISOString(),
}),
).toBe(true)
createdRecordingIds.delete(orphan.recording.id)
await expect(
payload.findByID({ collection: 'recording', id: orphan.recording.id, overrideAccess: true }),
).rejects.toThrow()
})
it('polls the visible statuses and deletes a recording with its media', async () => {
const { recording, media } = await createRecording({ status: 'failed', title: 'Para apagar' })
getCampaignUserMock.mockResolvedValue(communicator)
const statuses = await getRecordingStatusesForActor({ recordingIds: [recording.id] })
expect(statuses).toHaveLength(1)
expect(statuses[0]).toMatchObject({ id: recording.id, status: 'failed' })
await deleteRecordingForActor({ recordingId: recording.id })
createdRecordingIds.delete(recording.id)
await expect(
payload.findByID({ collection: 'recording', id: recording.id, overrideAccess: true }),
).rejects.toThrow()
if (media) {
// The media cleanup runs after commit, best-effort; poll for it.
let deleted = false
for (let attempt = 0; attempt < 20 && !deleted; attempt += 1) {
deleted = await payload
.findByID({ collection: 'recordingMedia', id: media.id, overrideAccess: true })
.then(() => false)
.catch(() => true)
if (!deleted) await new Promise((resolve) => setTimeout(resolve, 50))
}
expect(deleted).toBe(true)
createdMediaIds.delete(media.id)
}
})
it('finds the spoken excerpt of any person through the recordings search', async () => {
// A unique term keeps the assertion independent from residue left in the
// shared test database by earlier runs.
const marker = `merenda${Date.now().toString(36)}`
const { recording } = await createRecording({
title: `Comissão de Educação ${marker}`,
segments: [
{ startSeconds: 0, endSeconds: 6, text: `A deputada falou sobre a ${marker} escolar.` },
{ startSeconds: 6, endSeconds: 12, text: 'O relator respondeu em seguida.' },
],
})
await createRecording({ title: 'Outra gravação', segments: [] })
const data = await loadRecordingsPageData(payload, communicator, {
source: 'enviadas',
q: marker,
})
expect(data.rows).toHaveLength(1)
expect(data.rows[0]?.id).toBe(recording.id)
expect(data.rows[0]?.excerpt?.parts.map((part) => part.text).join('')).toContain(marker)
expect(data.rows[0]?.watchHref).toContain('t=0')
expect(data.rows[0]?.watchHref).toContain(`q=${marker}`)
const unfiltered = await loadRecordingsPageData(payload, communicator, {
source: 'enviadas',
})
expect(unfiltered.rows.length).toBeGreaterThanOrEqual(2)
expect(unfiltered.rows.every((row) => row.excerpt === null)).toBe(true)
})
})

View file

@ -18,9 +18,9 @@ vi.mock('next/cache', () => ({
import { buildSpeechPosterFfmpegArgs, speechPosterFilename } from '@/lib/speechPoster'
import config from '@/payload.config'
import { runFfmpeg } from '@/utilities/media/ffmpeg'
import type { SpeechImportBundle } from '@/utilities/speech/speechImport'
import { upsertSpeechBundle } from '@/utilities/speech/speechImport'
import { runFfmpeg } from '@/utilities/speech/speechMediaPipeline'
import { ensureSpeechPoster, findSpeechPosterMedia } from '@/utilities/speech/speechPosterJob'
import { camaraVodStub, PLAYBACK_URL } from '../helpers/camaraVodStub'

View file

@ -64,9 +64,17 @@ describe('campaignPageChrome', () => {
})
expect(resolveCampaignPageChrome('/campanha/comunicacao/acervo', 'communicator')).toEqual({
title: 'Acervo de falas',
subtitle: 'Discursos do Deputado Jorge Solla na Câmara.',
subtitle: 'Busque nas falas da Câmara ou nas gravações da equipe.',
})
expect(resolveCampaignPageChrome('/campanha/comunicacao/acervo/42', 'communicator')).toBeNull()
// C199 — the recordings alias has its own entry BEFORE the generic detail
// regex, which would otherwise read `gravacoes` as a speech id.
expect(
resolveCampaignPageChrome('/campanha/comunicacao/acervo/gravacoes', 'communicator'),
).toEqual({ title: 'Gravações enviadas' })
expect(
resolveCampaignPageChrome('/campanha/comunicacao/acervo/gravacoes/7', 'communicator'),
).toBeNull()
})
it('resolves the reel library list and leaves the detail title to the page (C194)', () => {

View file

@ -218,6 +218,10 @@ describe('campaign JSON mutation route convention', () => {
'src/app/(campaign)/campanha/api/ai-transcribe/route.ts',
'multipart STT endpoint (audio FormData, not JSON) — sibling of ai-chat, cookie-authenticated via campaign-token + explicit same-origin check',
],
[
'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/agenda/google-webhook/[secret]/route.ts',
'Google Calendar push delivery (empty body, not JSON) — public by design, authenticated by URL secret + X-Goog channel headers (never a campaign cookie)',

View file

@ -0,0 +1,96 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
PRIVATE_MEDIA_FALLBACK_MIME_TYPE,
privateMediaContentDisposition,
privateMediaContentType,
privateMediaHeaders,
type PrivateMediaRange,
} from '@/lib/privateMedia'
const fullRange: PrivateMediaRange = {
status: 200,
headers: { 'Accept-Ranges': 'bytes', 'Content-Length': '100' },
}
const partialRange: PrivateMediaRange = {
status: 206,
headers: { 'Accept-Ranges': 'bytes', 'Content-Length': '10', 'Content-Range': 'bytes 0-9/100' },
}
const invalidRange: PrivateMediaRange = {
status: 416,
headers: { 'Content-Range': 'bytes */100' },
}
describe('private media response rules (C193/C199)', () => {
it('serves only allowlisted types inline; everything else degrades', () => {
expect(privateMediaContentType('video/mp4')).toBe('video/mp4')
expect(privateMediaContentType('video/quicktime')).toBe('video/quicktime')
expect(privateMediaContentType('video/x-matroska')).toBe('video/x-matroska')
expect(privateMediaContentType('audio/mpeg')).toBe('audio/mpeg')
expect(privateMediaContentType('image/jpeg')).toBe('image/jpeg')
expect(privateMediaContentType('text/html')).toBe(PRIVATE_MEDIA_FALLBACK_MIME_TYPE)
expect(privateMediaContentType(null)).toBe(PRIVATE_MEDIA_FALLBACK_MIME_TYPE)
})
it('builds both filename forms and encodes non-ASCII', () => {
expect(privateMediaContentDisposition('reel.mp4', false)).toBe(
'inline; filename="reel.mp4"; filename*=UTF-8\'\'reel.mp4',
)
expect(privateMediaContentDisposition('capa ção.png', true)).toBe(
'attachment; filename="capa_o.png"; filename*=UTF-8\'\'capa%20%C3%A7%C3%A3o.png',
)
expect(privateMediaContentDisposition(null, false)).toContain('filename="arquivo"')
})
it('keeps the range headers and adds the private streaming contract', () => {
const headers = privateMediaHeaders({
range: partialRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: false,
})
expect(headers.get('Content-Range')).toBe('bytes 0-9/100')
expect(headers.get('Content-Length')).toBe('10')
expect(headers.get('Accept-Ranges')).toBe('bytes')
expect(headers.get('Content-Type')).toBe('video/mp4')
expect(headers.get('Content-Disposition')).toContain('inline')
expect(headers.get('Cache-Control')).toBe('private, no-store')
expect(headers.get('X-Content-Type-Options')).toBe('nosniff')
expect(headers.get('Content-Security-Policy')).toBeNull()
})
it('forces a download for unsafe types and for ?download=1', () => {
const unsafe = privateMediaHeaders({
range: fullRange,
mimeType: 'text/html',
filename: 'roteiro.html',
download: false,
})
expect(unsafe.get('Content-Type')).toBe(PRIVATE_MEDIA_FALLBACK_MIME_TYPE)
expect(unsafe.get('Content-Disposition')).toContain('attachment')
expect(unsafe.get('Content-Security-Policy')).toBe("default-src 'none'")
const explicit = privateMediaHeaders({
range: fullRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: true,
})
expect(explicit.get('Content-Disposition')).toContain('attachment')
})
it('passes the 416 headers through untouched', () => {
const headers = privateMediaHeaders({
range: invalidRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: false,
})
expect(headers.get('Content-Range')).toBe('bytes */100')
expect(headers.get('Content-Type')).toBe('video/mp4')
})
})

View file

@ -0,0 +1,183 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
canRetryRecording,
canServeRecordingMedia,
formatRecordingFileSize,
isRecordingStatus,
RECORDING_FAILURE_INTERRUPTED,
RECORDING_MAX_BYTES,
RECORDING_MAX_SIZE_LABEL,
RECORDING_STATUSES,
RECORDING_STEPS,
recordingFailureMessage,
recordingFileTypeAllowed,
recordingStatusLabels,
recordingTooLargeMessage,
recordingUploadTooLargeMessage,
toRecordingViewModel,
} from '@/lib/recording'
import {
buildRecordingAudioFfmpegArgs,
mergeChunkTranscriptions,
RECORDING_AUDIO_CHUNK_SECONDS,
recordingSearchText,
} from '@/lib/recordingTranscription'
describe('recording vocabulary (C199)', () => {
it('pins the statuses, steps and their labels', () => {
expect(RECORDING_STATUSES).toEqual(['uploading', 'processing', 'ready', 'failed'])
expect(Object.keys(recordingStatusLabels).sort()).toEqual([...RECORDING_STATUSES].sort())
expect(RECORDING_STEPS).toEqual(['extracting', 'transcribing', 'saving'])
expect(isRecordingStatus('failed')).toBe(true)
expect(isRecordingStatus('published')).toBe(false)
})
it('serves media from processing on, never during the upload stream', () => {
expect(canServeRecordingMedia('uploading')).toBe(false)
expect(canServeRecordingMedia('processing')).toBe(true)
expect(canServeRecordingMedia('failed')).toBe(true)
expect(canServeRecordingMedia('ready')).toBe(true)
})
it('only offers retry for a failed transcription', () => {
expect(canRetryRecording('failed')).toBe(true)
expect(canRetryRecording('ready')).toBe(false)
expect(canRetryRecording('processing')).toBe(false)
})
it('maps the failure cause to honest copy', () => {
expect(
recordingFailureMessage({ error: RECORDING_FAILURE_INTERRUPTED, step: 'transcribing' }),
).toBe(RECORDING_FAILURE_INTERRUPTED)
expect(recordingFailureMessage({ error: 'raw stderr detail', step: 'extracting' })).toBe(
'Não foi possível preparar o áudio da gravação.',
)
expect(recordingFailureMessage({ error: 'raw stderr detail', step: 'transcribing' })).toBe(
'Não foi possível transcrever a gravação.',
)
expect(recordingFailureMessage({ error: 'raw stderr detail', step: 'saving' })).toBe(
'Não foi possível guardar a transcrição.',
)
expect(recordingFailureMessage({ error: 'raw detail', step: null })).toBe(
'Não foi possível transcrever a gravação.',
)
expect(recordingFailureMessage({ error: null, step: 'transcribing' })).toBeNull()
expect(recordingFailureMessage({ error: ' ', step: 'transcribing' })).toBeNull()
})
it('accepts videos and unknown types, refuses every other type', () => {
expect(recordingFileTypeAllowed('video/mp4')).toBe(true)
expect(recordingFileTypeAllowed('video/quicktime')).toBe(true)
expect(recordingFileTypeAllowed('')).toBe(true)
expect(recordingFileTypeAllowed(null)).toBe(true)
expect(recordingFileTypeAllowed('text/plain')).toBe(false)
expect(recordingFileTypeAllowed('image/png')).toBe(false)
})
it('formats sizes in pt-BR and names the limit', () => {
expect(formatRecordingFileSize(4.6 * 1024 ** 3)).toContain('GB')
expect(formatRecordingFileSize(812 * 1024 ** 2)).toBe('812 MB')
expect(recordingTooLargeMessage(4.6 * 1024 ** 3)).toContain('4,6 GB')
expect(recordingTooLargeMessage(4.6 * 1024 ** 3)).toContain(RECORDING_MAX_SIZE_LABEL)
expect(recordingUploadTooLargeMessage).toContain(RECORDING_MAX_SIZE_LABEL)
expect(RECORDING_MAX_BYTES).toBe(4 * 1024 ** 3)
})
it('formats the byte range below 1 KB honestly', () => {
expect(formatRecordingFileSize(0)).toBe('0 B')
expect(formatRecordingFileSize(900)).toBe('900 B')
expect(formatRecordingFileSize(2048)).toBe('2 KB')
})
it('builds the view model with labels and hrefs', () => {
const view = toRecordingViewModel({
id: 7,
title: ' Plenária da Comissão ',
status: 'ready',
step: null,
recordedAt: '2026-09-01T00:00:00.000Z',
durationSeconds: 3725,
})
expect(view.title).toBe('Plenária da Comissão')
expect(view.recordedAtLabel).toBe('01/09/2026')
expect(view.durationLabel).toBe('1:02:05')
expect(view.detailHref).toBe('/campanha/comunicacao/acervo/gravacoes/7')
expect(view.fileHref).toBe('/campanha/comunicacao/acervo/gravacoes/7/arquivo')
expect(view.downloadHref).toBe('/campanha/comunicacao/acervo/gravacoes/7/arquivo?download=1')
expect(view.retryHref).toBe('/campanha/comunicacao/acervo/gravacoes/7/retry')
expect(view.deleteHref).toBe('/campanha/comunicacao/acervo/gravacoes/7/apagar')
const degraded = toRecordingViewModel({
id: 8,
title: '',
status: 'stray',
step: 'stray',
recordedAt: null,
durationSeconds: null,
})
expect(degraded.title).toBe('Gravação 8')
expect(degraded.status).toBe('failed')
expect(degraded.step).toBeNull()
expect(degraded.recordedAtLabel).toBeNull()
expect(degraded.durationLabel).toBeNull()
})
})
describe('recording transcription (C199)', () => {
it('builds the extract-and-split ffmpeg command', () => {
const args = buildRecordingAudioFfmpegArgs({
inputPath: '/tmp/in.mkv',
outputPattern: '/tmp/chunk-%03d.mp3',
})
expect(args).toContain('-vn')
expect(args).toContain('libmp3lame')
expect(args[args.indexOf('-segment_time') + 1]).toBe(String(RECORDING_AUDIO_CHUNK_SECONDS))
expect(args.at(-1)).toBe('/tmp/chunk-%03d.mp3')
})
it('merges chunks with absolute offsets, contiguous order and clamped spans', () => {
const merged = mergeChunkTranscriptions([
{
offsetSeconds: 0,
segments: [
{ start: 1, end: 4, text: 'primeiro trecho' },
{ start: 4, end: 2, text: 'clamp aqui' },
{ start: 5, end: 6, text: ' ' },
],
},
{
offsetSeconds: 1200,
segments: [{ start: 0.5, end: 3, text: 'segundo chunk' }],
},
])
expect(merged).toHaveLength(3)
expect(merged[0]).toEqual({
order: 0,
startSeconds: 1,
endSeconds: 4,
text: 'primeiro trecho',
})
expect(merged[1]).toEqual({ order: 1, startSeconds: 4, endSeconds: 4, text: 'clamp aqui' })
expect(merged[2]).toMatchObject({
order: 2,
startSeconds: 1200.5,
endSeconds: 1203,
text: 'segundo chunk',
})
})
it('normalizes the concatenated search text', () => {
expect(recordingSearchText([{ text: 'Ação da Comissão' }, { text: ' de EDUCAÇÃO ' }])).toBe(
'acao da comissao de educacao',
)
})
it('returns nothing for an empty transcription', () => {
expect(mergeChunkTranscriptions([])).toEqual([])
expect(recordingSearchText([])).toBe('')
})
})

View file

@ -0,0 +1,71 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
buildAcervoSourceHref,
buildRecordingListHref,
parseAcervoSource,
parseRecordingListParams,
resolveRecordingListUrl,
} from '@/utilities/recordings/recordingListUrl'
describe('recording list URL (C199)', () => {
it('detects the enviadas source and defaults everything else to the Câmara', () => {
expect(parseAcervoSource({ source: 'enviadas' })).toBe('enviadas')
expect(parseAcervoSource({ source: 'camara' })).toBe('camara')
expect(parseAcervoSource({ source: 'gravacoes' })).toBe('camara')
expect(parseAcervoSource({})).toBe('camara')
})
it('parses the canonical state and rejects an unready query', () => {
expect(parseRecordingListParams({ source: 'enviadas', q: 'merenda', page: '3' })).toEqual({
source: 'enviadas',
page: 3,
q: 'merenda',
})
// One character is not a ready search query (same rule as contact search).
expect(parseRecordingListParams({ source: 'enviadas', q: 'a' })).toEqual({
source: 'enviadas',
page: 1,
})
expect(parseRecordingListParams({ source: 'enviadas', page: '0' }).page).toBe(1)
})
it('keeps `source=enviadas` in the canonical href and drops unsupported params', () => {
const canonical = resolveRecordingListUrl({ source: 'enviadas', q: 'merenda' })
expect(canonical.state).toEqual({ source: 'enviadas', page: 1, q: 'merenda' })
expect(canonical.href).toBe('/campanha/comunicacao/acervo?source=enviadas&q=merenda')
expect(canonical.redirectHref).toBeUndefined()
const polluted = resolveRecordingListUrl({
source: 'enviadas',
q: 'merenda',
year: '2026',
topic: 'saude',
})
expect(polluted.redirectHref).toBe('/campanha/comunicacao/acervo?source=enviadas&q=merenda')
const unready = resolveRecordingListUrl({ source: 'enviadas', q: 'a' })
expect(unready.href).toBe('/campanha/comunicacao/acervo?source=enviadas')
expect(unready.redirectHref).toBe('/campanha/comunicacao/acervo?source=enviadas')
})
it('clamps the page to the last one and builds pagination hrefs', () => {
const clamped = resolveRecordingListUrl({ source: 'enviadas', page: '9' }, 3)
expect(clamped.state.page).toBe(3)
expect(clamped.href).toBe('/campanha/comunicacao/acervo?source=enviadas&page=3')
expect(buildRecordingListHref({ source: 'enviadas', page: 1 }, 2)).toBe(
'/campanha/comunicacao/acervo?source=enviadas&page=2',
)
expect(buildRecordingListHref({ source: 'enviadas', page: 2 }, 1)).toBe(
'/campanha/comunicacao/acervo?source=enviadas',
)
})
it('builds the switcher hrefs (the Câmara side is the bare acervo)', () => {
expect(buildAcervoSourceHref('enviadas')).toBe('/campanha/comunicacao/acervo?source=enviadas')
expect(buildAcervoSourceHref('camara')).toBe('/campanha/comunicacao/acervo')
})
})

View file

@ -20,28 +20,6 @@ import {
toReelLibraryItemViewModel,
type ReelRecordForView,
} from '@/lib/reel'
import {
REEL_MEDIA_FALLBACK_MIME_TYPE,
reelMediaContentDisposition,
reelMediaContentType,
reelMediaHeaders,
type ReelMediaRange,
} from '@/lib/reelMedia'
const fullRange: ReelMediaRange = {
status: 200,
headers: { 'Accept-Ranges': 'bytes', 'Content-Length': '100' },
}
const partialRange: ReelMediaRange = {
status: 206,
headers: { 'Accept-Ranges': 'bytes', 'Content-Length': '10', 'Content-Range': 'bytes 0-9/100' },
}
const invalidRange: ReelMediaRange = {
status: 416,
headers: { 'Content-Range': 'bytes */100' },
}
describe('reel vocabulary (C193)', () => {
it('pins the status kill switch and its labels', () => {
@ -73,74 +51,6 @@ describe('reel vocabulary (C193)', () => {
})
})
describe('reel media response rules (C193)', () => {
it('serves only allowlisted types inline; everything else degrades', () => {
expect(reelMediaContentType('video/mp4')).toBe('video/mp4')
expect(reelMediaContentType('audio/mpeg')).toBe('audio/mpeg')
expect(reelMediaContentType('image/jpeg')).toBe('image/jpeg')
expect(reelMediaContentType('text/html')).toBe(REEL_MEDIA_FALLBACK_MIME_TYPE)
expect(reelMediaContentType(null)).toBe(REEL_MEDIA_FALLBACK_MIME_TYPE)
})
it('builds both filename forms and encodes non-ASCII', () => {
expect(reelMediaContentDisposition('reel.mp4', false)).toBe(
'inline; filename="reel.mp4"; filename*=UTF-8\'\'reel.mp4',
)
expect(reelMediaContentDisposition('capa ção.png', true)).toBe(
'attachment; filename="capa_o.png"; filename*=UTF-8\'\'capa%20%C3%A7%C3%A3o.png',
)
expect(reelMediaContentDisposition(null, false)).toContain('filename="arquivo"')
})
it('keeps the range headers and adds the private streaming contract', () => {
const headers = reelMediaHeaders({
range: partialRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: false,
})
expect(headers.get('Content-Range')).toBe('bytes 0-9/100')
expect(headers.get('Content-Length')).toBe('10')
expect(headers.get('Accept-Ranges')).toBe('bytes')
expect(headers.get('Content-Type')).toBe('video/mp4')
expect(headers.get('Content-Disposition')).toContain('inline')
expect(headers.get('Cache-Control')).toBe('private, no-store')
expect(headers.get('X-Content-Type-Options')).toBe('nosniff')
expect(headers.get('Content-Security-Policy')).toBeNull()
})
it('forces a download for unsafe types and for ?download=1', () => {
const unsafe = reelMediaHeaders({
range: fullRange,
mimeType: 'text/html',
filename: 'roteiro.html',
download: false,
})
expect(unsafe.get('Content-Type')).toBe(REEL_MEDIA_FALLBACK_MIME_TYPE)
expect(unsafe.get('Content-Disposition')).toContain('attachment')
expect(unsafe.get('Content-Security-Policy')).toBe("default-src 'none'")
const explicit = reelMediaHeaders({
range: fullRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: true,
})
expect(explicit.get('Content-Disposition')).toContain('attachment')
})
it('passes the 416 headers through untouched', () => {
const headers = reelMediaHeaders({
range: invalidRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: false,
})
expect(headers.get('Content-Range')).toBe('bytes */100')
expect(headers.get('Content-Type')).toBe('video/mp4')
})
})
const baseRecord: ReelRecordForView = {
id: 42,
title: 'Como criar seu card de apoio',