mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C199): gravações enviadas no acervo de comunicação (Closes #1166)
This commit is contained in:
parent
fdbab4b91c
commit
d0f606a55d
72 changed files with 22754 additions and 309 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -58,6 +58,7 @@ next-env.d.ts
|
|||
|
||||
/media
|
||||
/reelMedia
|
||||
/recordingMedia
|
||||
|
||||
# TSE open-data zip cache (pnpm db:seed:tse)
|
||||
/data/tse/
|
||||
|
|
|
|||
1
docs/changelog/2026-09-18-c199.md
Normal file
1
docs/changelog/2026-09-18-c199.md
Normal 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).
|
||||
227
docs/plans/acervo-gravacoes-enviadas-impl.md
Normal file
227
docs/plans/acervo-gravacoes-enviadas-impl.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
43
docs/plans/gates-gravacoes-predicados.md
Normal file
43
docs/plans/gates-gravacoes-predicados.md
Normal 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.
|
||||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
})
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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'))
|
||||
}
|
||||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
185
src/app/(campaign)/campanha/actions/recording.ts
Normal file
185
src/app/(campaign)/campanha/actions/recording.ts
Normal 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 }
|
||||
}
|
||||
147
src/collections/Recording.ts
Normal file
147
src/collections/Recording.ts
Normal 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 }),
|
||||
],
|
||||
}
|
||||
48
src/collections/RecordingMedia.ts
Normal file
48
src/collections/RecordingMedia.ts
Normal 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,
|
||||
}
|
||||
84
src/collections/RecordingSegment.ts
Normal file
84
src/collections/RecordingSegment.ts
Normal 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.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
40
src/components/campaign/recording/AcervoSourceToggle.tsx
Normal file
40
src/components/campaign/recording/AcervoSourceToggle.tsx
Normal 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>
|
||||
)
|
||||
117
src/components/campaign/recording/RecordingDeleteDialog.tsx
Normal file
117
src/components/campaign/recording/RecordingDeleteDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
225
src/components/campaign/recording/RecordingDetailPlayer.tsx
Normal file
225
src/components/campaign/recording/RecordingDetailPlayer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
src/components/campaign/recording/RecordingResultCard.tsx
Normal file
82
src/components/campaign/recording/RecordingResultCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
src/components/campaign/recording/RecordingResultList.tsx
Normal file
10
src/components/campaign/recording/RecordingResultList.tsx
Normal 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>
|
||||
)
|
||||
86
src/components/campaign/recording/RecordingRetryButton.tsx
Normal file
86
src/components/campaign/recording/RecordingRetryButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/components/campaign/recording/RecordingStatusBadge.tsx
Normal file
28
src/components/campaign/recording/RecordingStatusBadge.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
388
src/components/campaign/recording/RecordingUploadDialog.tsx
Normal file
388
src/components/campaign/recording/RecordingUploadDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
82
src/lib/privateMedia.ts
Normal 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
186
src/lib/recording.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
143
src/lib/recordingTranscription.ts
Normal file
143
src/lib/recordingTranscription.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
62
src/lib/schemas/recording.ts
Normal file
62
src/lib/schemas/recording.ts
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
16560
src/migrations/20260919_003440_add_recording.json
Normal file
16560
src/migrations/20260919_003440_add_recording.json
Normal file
File diff suppressed because it is too large
Load diff
111
src/migrations/20260919_003440_add_recording.ts
Normal file
111
src/migrations/20260919_003440_add_recording.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
44
src/utilities/access/recordings.ts
Normal file
44
src/utilities/access/recordings.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,3 +177,9 @@ export {
|
|||
} from '@/utilities/access/speeches'
|
||||
|
||||
export { canDeleteReel, canReadReel } from '@/utilities/access/reels'
|
||||
|
||||
export {
|
||||
canDeleteRecording,
|
||||
canReadRecording,
|
||||
canUploadRecording,
|
||||
} from '@/utilities/access/recordings'
|
||||
|
|
|
|||
55
src/utilities/media/ffmpeg.ts
Normal file
55
src/utilities/media/ffmpeg.ts
Normal 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}` : ''}`))
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
274
src/utilities/recordings/recordingJob.ts
Normal file
274
src/utilities/recordings/recordingJob.ts
Normal 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
|
||||
}
|
||||
16
src/utilities/recordings/recordingListFilters.ts
Normal file
16
src/utilities/recordings/recordingListFilters.ts
Normal 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 } } : {}
|
||||
}
|
||||
83
src/utilities/recordings/recordingListUrl.ts
Normal file
83
src/utilities/recordings/recordingListUrl.ts
Normal 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,
|
||||
})
|
||||
233
src/utilities/recordings/recordingPageData.ts
Normal file
233
src/utilities/recordings/recordingPageData.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
20
src/utilities/recordings/recordingScheduler.ts
Normal file
20
src/utilities/recordings/recordingScheduler.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
144
src/utilities/recordings/recordingUpload.ts
Normal file
144
src/utilities/recordings/recordingUpload.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
133
src/utilities/recordings/recordingViewModels.ts
Normal file
133
src/utilities/recordings/recordingViewModels.ts
Normal 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 ?? ''),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}` : ''}`))
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
22
tests/fixtures/fake-ffmpeg.mjs
vendored
22
tests/fixtures/fake-ffmpeg.mjs
vendored
|
|
@ -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)
|
||||
|
|
|
|||
558
tests/int/recording.int.spec.ts
Normal file
558
tests/int/recording.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
96
tests/unit/privateMedia.unit.spec.ts
Normal file
96
tests/unit/privateMedia.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
183
tests/unit/recording.unit.spec.ts
Normal file
183
tests/unit/recording.unit.spec.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
71
tests/unit/recordingListUrl.unit.spec.ts
Normal file
71
tests/unit/recordingListUrl.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue