From d0f606a55d61750ee29e9435ffa1d308a6bc08dc Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 18 Sep 2026 22:59:03 -0300 Subject: [PATCH] =?UTF-8?q?feat(C199):=20grava=C3=A7=C3=B5es=20enviadas=20?= =?UTF-8?q?no=20acervo=20de=20comunica=C3=A7=C3=A3o=20(Closes=20#1166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docs/changelog/2026-09-18-c199.md | 1 + docs/plans/acervo-gravacoes-enviadas-impl.md | 227 + .../acervo-gravacoes-enviadas-ui-design.html | 693 +- docs/plans/gates-gravacoes-predicados.md | 43 + scripts/lib/e2e-affected-manifest.mjs | 12 +- .../(app)/comunicacao/acervo/[id]/page.tsx | 4 +- .../acervo/gravacoes/[id]/apagar/route.ts | 60 + .../acervo/gravacoes/[id]/arquivo/route.ts | 68 + .../acervo/gravacoes/[id]/page.tsx | 134 + .../acervo/gravacoes/[id]/retry/route.ts | 32 + .../acervo/gravacoes/enviar/route.ts | 122 + .../comunicacao/acervo/gravacoes/page.tsx | 13 + .../acervo/gravacoes/status/route.ts | 26 + .../comunicacao/acervo/gravacoes/types.ts | 21 + .../(app)/comunicacao/acervo/page.tsx | 152 +- .../reels/[id]/media/[kind]/route.ts | 11 +- .../(campaign)/campanha/actions/recording.ts | 185 + src/collections/Recording.ts | 147 + src/collections/RecordingMedia.ts | 48 + src/collections/RecordingSegment.ts | 84 + .../campaign/recording/AcervoSourceToggle.tsx | 40 + .../recording/RecordingDeleteDialog.tsx | 117 + .../recording/RecordingDetailPlayer.tsx | 225 + .../recording/RecordingResultCard.tsx | 82 + .../recording/RecordingResultList.tsx | 10 + .../recording/RecordingRetryButton.tsx | 86 + .../recording/RecordingStatusBadge.tsx | 28 + .../recording/RecordingStatusRefresher.tsx | 65 + .../recording/RecordingUploadDialog.tsx | 388 + src/components/ui/Pagination.tsx | 4 +- src/components/ui/dialog.tsx | 25 +- src/lib/campaignPageChrome.ts | 13 +- src/lib/campaignPaths.ts | 23 + src/lib/privateMedia.ts | 82 + src/lib/recording.ts | 186 + src/lib/recordingTranscription.ts | 143 + src/lib/reelMedia.ts | 66 - src/lib/schemas/recording.ts | 62 + src/lib/speechClock.ts | 10 + src/lib/speechHighlight.ts | 23 + .../20260919_003440_add_recording.json | 16560 ++++++++++++++++ .../20260919_003440_add_recording.ts | 111 + src/migrations/index.ts | 6 + src/payload-types.ts | 150 + src/payload.config.ts | 8 + src/utilities/access/recordings.ts | 44 + src/utilities/ai/deepInfraTranscribe.ts | 70 +- src/utilities/campaignAccess.ts | 6 + src/utilities/media/ffmpeg.ts | 55 + .../privateMediaResponse.ts} | 106 +- src/utilities/recordings/recordingJob.ts | 274 + .../recordings/recordingListFilters.ts | 16 + src/utilities/recordings/recordingListUrl.ts | 83 + src/utilities/recordings/recordingPageData.ts | 233 + .../recordings/recordingScheduler.ts | 20 + src/utilities/recordings/recordingUpload.ts | 144 + .../recordings/recordingViewModels.ts | 133 + src/utilities/speech/speechCutJob.ts | 3 +- src/utilities/speech/speechMediaPipeline.ts | 45 +- src/utilities/speech/speechPosterJob.ts | 3 +- src/utilities/speech/speechViewModels.ts | 24 +- tests/e2e/campaignSpeechAcervo.e2e.spec.ts | 171 + tests/fixtures/fake-ffmpeg.mjs | 22 +- tests/int/recording.int.spec.ts | 558 + tests/int/speechPoster.int.spec.ts | 2 +- tests/unit/campaignPageChrome.unit.spec.ts | 10 +- tests/unit/codebaseConventions.unit.spec.ts | 4 + tests/unit/privateMedia.unit.spec.ts | 96 + tests/unit/recording.unit.spec.ts | 183 + tests/unit/recordingListUrl.unit.spec.ts | 71 + tests/unit/reel.unit.spec.ts | 90 - 72 files changed, 22754 insertions(+), 309 deletions(-) create mode 100644 docs/changelog/2026-09-18-c199.md create mode 100644 docs/plans/acervo-gravacoes-enviadas-impl.md create mode 100644 docs/plans/gates-gravacoes-predicados.md create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/apagar/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/arquivo/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/page.tsx create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/retry/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/enviar/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/page.tsx create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/status/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/types.ts create mode 100644 src/app/(campaign)/campanha/actions/recording.ts create mode 100644 src/collections/Recording.ts create mode 100644 src/collections/RecordingMedia.ts create mode 100644 src/collections/RecordingSegment.ts create mode 100644 src/components/campaign/recording/AcervoSourceToggle.tsx create mode 100644 src/components/campaign/recording/RecordingDeleteDialog.tsx create mode 100644 src/components/campaign/recording/RecordingDetailPlayer.tsx create mode 100644 src/components/campaign/recording/RecordingResultCard.tsx create mode 100644 src/components/campaign/recording/RecordingResultList.tsx create mode 100644 src/components/campaign/recording/RecordingRetryButton.tsx create mode 100644 src/components/campaign/recording/RecordingStatusBadge.tsx create mode 100644 src/components/campaign/recording/RecordingStatusRefresher.tsx create mode 100644 src/components/campaign/recording/RecordingUploadDialog.tsx create mode 100644 src/lib/privateMedia.ts create mode 100644 src/lib/recording.ts create mode 100644 src/lib/recordingTranscription.ts delete mode 100644 src/lib/reelMedia.ts create mode 100644 src/lib/schemas/recording.ts create mode 100644 src/migrations/20260919_003440_add_recording.json create mode 100644 src/migrations/20260919_003440_add_recording.ts create mode 100644 src/utilities/access/recordings.ts create mode 100644 src/utilities/media/ffmpeg.ts rename src/utilities/{reels/reelMediaResponse.ts => privateMedia/privateMediaResponse.ts} (59%) create mode 100644 src/utilities/recordings/recordingJob.ts create mode 100644 src/utilities/recordings/recordingListFilters.ts create mode 100644 src/utilities/recordings/recordingListUrl.ts create mode 100644 src/utilities/recordings/recordingPageData.ts create mode 100644 src/utilities/recordings/recordingScheduler.ts create mode 100644 src/utilities/recordings/recordingUpload.ts create mode 100644 src/utilities/recordings/recordingViewModels.ts create mode 100644 tests/int/recording.int.spec.ts create mode 100644 tests/unit/privateMedia.unit.spec.ts create mode 100644 tests/unit/recording.unit.spec.ts create mode 100644 tests/unit/recordingListUrl.unit.spec.ts diff --git a/.gitignore b/.gitignore index 82b6fd21..d260ac82 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ next-env.d.ts /media /reelMedia +/recordingMedia # TSE open-data zip cache (pnpm db:seed:tse) /data/tse/ diff --git a/docs/changelog/2026-09-18-c199.md b/docs/changelog/2026-09-18-c199.md new file mode 100644 index 00000000..fd910e28 --- /dev/null +++ b/docs/changelog/2026-09-18-c199.md @@ -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). diff --git a/docs/plans/acervo-gravacoes-enviadas-impl.md b/docs/plans/acervo-gravacoes-enviadas-impl.md new file mode 100644 index 00000000..13943aec --- /dev/null +++ b/docs/plans/acervo-gravacoes-enviadas-impl.md @@ -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
raw body + title/date na query"| U[rota de upload
isSameOrigin + getCampaignUser + canUploadRecording] + U -->|"cria row status=uploading"| R[(recording)] + U -->|"stream p/ temp + limite 4 GiB"| T[filePath] + T -->|"tx: recordingMedia + row
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
verbose_json + segment"| DI[Deep Infra Whisper] + DI -->|"merge offsets"| J + J -->|"tx: recordingSegment[] + searchText
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/`) 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/?t=&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 ``s com `aria-current` e o mesmo estilo de pill (navegação, não tab widget); `` 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. diff --git a/docs/plans/acervo-gravacoes-enviadas-ui-design.html b/docs/plans/acervo-gravacoes-enviadas-ui-design.html index 1ed71e17..195ff67a 100644 --- a/docs/plans/acervo-gravacoes-enviadas-ui-design.html +++ b/docs/plans/acervo-gravacoes-enviadas-ui-design.html @@ -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 @@ + + + + + + + + + + + + + +
@@ -280,18 +314,18 @@ Selecionar arquivo localO limite será informado antes do envio.Vídeos até 4 GB · MP4, MOV, MKV e formatos compatíveis @@ -617,8 +651,8 @@ @@ -638,9 +672,13 @@
- Falas da CâmaraGravações enviadasFalas da CâmaraGravações enviadas
-
+

Cena 6 · Mobile (390 px) — lista e detalhe com transcrição

@@ -680,12 +718,15 @@
- +
-
- + +
+

+ Cena 7 · Desktop — confirmação destrutiva ao apagar +

+
+
+ +
+
+

Título da gravação

+

dd/mm/aaaa · —:—:—

+
+ +
+
+
+
+
+ + + +
+

Apagar esta gravação?

+

+ O arquivo e toda a transcrição serão removidos. Esta ação não pode ser desfeita. +

+
+
+
+ + +
+
+
+
+ Decisão visual: 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. +
+
+ + +
+

+ Cena 8 · Desktop — detalhe em Enviando, Processando e Falhou +

+
+
+
+
+
+ Enviando +

Título da gravação

+

dd/mm/aaaa

+
+ +
+
+ +

O arquivo ainda está sendo enviado

+

+ Player e download aparecem quando o envio terminar. +

+
+
+
+
+
+ Enviando arquivo…58% +
+
+ +
+
+
+ Processando +

Título da gravação

+

dd/mm/aaaa · —:—:—

+
+ +
+
+ +
+ +
+
+ + Transcrição em processamento +
+

+ O vídeo já pode ser assistido. A transcrição ainda não está disponível. +

+
+
+ +
+
+
+ Falhou +

Título da gravação

+

dd/mm/aaaa · —:—:—

+
+ +
+
+ +
+
+

A transcrição não foi concluída

+

Não foi possível processar o áudio. O arquivo foi preservado.

+
+
+ + +
+
+
+
+

+ 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. +

+
+ + +
+

+ Cena 9 · Desktop — reprocessamento em andamento e falha ao iniciar +

+
+
+
+ Processando +

Reprocessando a transcrição

+

+ O arquivo foi preservado. Você pode assistir enquanto a nova transcrição é + preparada. +

+ +

+ Ao aceitar o pedido, a tela troca de “Falhou” para “Processando”. +

+
+
+ Falhou +

A transcrição continua pendente

+

+ O erro original continua visível até uma nova tentativa ser iniciada. +

+ + +
+
+
+

+ 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. +

+
+ + +
+

+ Cena 10 · Desktop — arquivo acima do limite no diálogo +

+
+
+ +
+

+ 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”. +

+
+ + +
+

Cena 11 · Desktop — diálogo durante o envio

+
+
+ +
+
+ + +
+

Cena 12 · Desktop — paginação e vazio da busca

+
+
+ +
+

+ Busca sem resultado +

+
+ + + +

+ Nenhuma gravação encontrada para “termo buscado” +

+

+ Tente outro termo ou limpe a busca para ver todas as gravações enviadas. +

+ +
+
+
+
+

+ 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”. +

+
+ + +
+

+ Cena 13 · Mobile (390 px) — extensão dos estados críticos +

+
+
+

Detalhe com falha

+
+
+ +
+

Gravação enviada

+

Acervo

+
+
+
+
+
+

Título da gravação

+

dd/mm/aaaa · —:—:—

+
+ Falhou +
+
+ +
+
+

A transcrição não foi concluída

+

Não foi possível processar o áudio. O arquivo foi preservado.

+
+ + + +
+
+
+ +
+

Confirmação de apagar

+
+
+
+ + + +

+ Apagar esta gravação? +

+

+ O arquivo e toda a transcrição serão removidos. Esta ação não pode ser desfeita. +

+
+ + +
+
+
+
+ +
+

Envio em andamento

+
+
+
+

Enviando gravação

+

+ Mantenha esta janela aberta até concluir. +

+
+
+ + + +

gravacao-plenaria.mkv

+ 58% +
+
+
+
+

Enviando arquivo…

+
+
+ Depois do envio, a gravação aparecerá como “Processando”. +
+ +
+
+
+ +
+

Busca sem resultado

+
+
+

Acervo

+
+
+ +
+ + + +

+ Nenhuma gravação encontrada para “termo buscado” +

+

+ Tente outro termo ou limpe a busca. +

+ +
+
+
+
+
+
+ + +
+

Anotações semânticas obrigatórias para o port

+
+
    +
  • + O alternador de fonte é navegação: dois links com + aria-current="page" no + ativo, mantendo a aparência de pill. Não é widget de abas. +
  • +
  • + “Data da gravação” usa o controle nativo + type="date". +
  • +
  • Todo texto visível, rótulo, feedback e mensagem de erro permanece em pt-BR.
  • +
+
    +
  • + Toda ação destrutiva exige confirmação. “Apagar” aparece somente no detalhe e abre a + Cena 7; nunca apaga diretamente no card. +
  • +
  • + 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. +
  • +
  • + 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. +
  • +
+
+
+ diff --git a/docs/plans/gates-gravacoes-predicados.md b/docs/plans/gates-gravacoes-predicados.md new file mode 100644 index 00000000..02c136e3 --- /dev/null +++ b/docs/plans/gates-gravacoes-predicados.md @@ -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. diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 5cc1b593..ac22a492 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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', ], diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx index 398a0786..aaedd91a 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx @@ -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) => { diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/apagar/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/apagar/route.ts new file mode 100644 index 00000000..6d6a2bab --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/apagar/route.ts @@ -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> => { + 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({ status: 'success', deleted: true }) + } catch (error) { + return campaignJsonMutationErrorResponse(error, { + safeMessages: [RECORDING_FORBIDDEN_MESSAGE, RECORDING_NOT_FOUND_MESSAGE], + genericMessage: RECORDING_GENERIC_ERROR_MESSAGE, + }) + } +} diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/arquivo/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/arquivo/route.ts new file mode 100644 index 00000000..2d94180d --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/arquivo/route.ts @@ -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 `