feat(C168): biblioteca de cortes no acervo (origem, download, compartilhar, editar texto)

Lista + detalhe em /campanha/comunicacao/acervo/cortes: busca por título/descrição,\norigem da fala, player do MP4, edição de título/descrição e kill switch de\npublicar/despublicar no mesmo /corte/<id>. Gate speechCatalog fail-closed para\nadvisor/leader. Sem collection/migration nova.
This commit is contained in:
Francisco Solla 2026-09-15 11:28:15 -03:00
parent 352801853f
commit 2d1fd5f158
33 changed files with 1921 additions and 50 deletions

View file

@ -0,0 +1 @@
**C168 — Acervo: biblioteca de cortes (origem, download, compartilhar, editar título/descrição) (2026-09-15):** a vertical Comunicação ganha a **biblioteca do que já foi cortado**, para o corte não morrer no instante em que o link é enviado. Nova rota **`/campanha/comunicacao/acervo/cortes`** lista os cortes mais recentes primeiro, cada item com status (publicado/despublicado/processando/falhou), data de criação, a **fala de origem** (tipo/fase/data com link para o acervo) e a duração, com busca por título/descrição (`like` ILIKE em `title`/`description`, sem migração — o volume não paga `searchText`/índice; gatilho: ~5–10 mil cortes ou busca acento-insensível) e a paginação padrão da casa pelo mesmo esqueleto do acervo de falas (`CampaignPageShell`/`CampaignListPendingBoundary`/`Footer`/`EmptyState`, omnibox degenerado `searchOnlyListOmnibox`). O detalhe **`.../cortes/[id]`** toca o **mesmo MP4** do público num `<video>` simples, mostra o discurso de origem (degradando quando a fala foi excluída — FK `SET NULL`), edita **título/descrição** inline e tem o **kill switch** de publicar/despublicar com a copy aprovada no `AlertDialog` ("Despublicar este corte?" → "O link público deixa de funcionar para quem já recebeu. Você pode publicar de novo quando quiser."); despublicado, o link `/corte/<id>` para de responder e **republicar restaura o MESMO link** (id imutável, `publishedAt` = última publicação, bust de cache pelo `afterChange` do C167). No detalhe também se baixa o MP4, copia o link e compartilha no WhatsApp (`SpeechCutShareActions`, o link é o da página pública); para corte despublicado sobra só o download (o link está morto). Acesso intocado no domínio: `communicator`/`coordinator`/`candidate` leem/editam, `advisor`/`leader` negados fail-closed (gate `speechCatalog` na página e repetido em cada action); o texto editável é o do CORTE, nunca o da fala (`canReadSpeech`, não `canUpdateSpeech`) nem o vídeo. Mutação em JSON routes (`campaignJsonMutationRoute`) — `.../cortes/[id]/texto` e `.../cortes/[id]/publicacao` — sobre `updateSpeechCutTextForActor`/`setSpeechCutPublishedForActor`; publicar é recusado enquanto não houver arquivo (`SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE`). Sem collection/migration nova, sem deletar/métricas/kits/versionamento, sem editor de vídeo. Testes: unit do contrato de URL (`speechCutListUrl`), int da lista/busca/ordem, origem (presente e degradada), edição refletida, matriz que nega advisor/leader e o ciclo do kill switch com mídia real (fake ffmpeg) provando que o anônimo deixa de ver o corte e que o link volta idêntico, e e2e browserless estendendo `campaignSpeechCut` (lista/detalhe 200 para communicator, edição e toggle por HTTP, 404 público após despublicar, e `advisor`/`leader` redirecionados na página e negados na rota de edição).

View file

@ -0,0 +1,186 @@
# Impl: Acervo: biblioteca de cortes (origem, download, compartilhar, editar título/descrição)
Status: aprovado
Atualizado em: 2026-09-15
Issue: #1015
Intenção: docs/plans/c168-biblioteca-cortes.md
Appetite restante: herdado (~1–1,5 dia eng)
## Leitura da intenção
- **Outcome:** a assessoria reencontra um corte já feito, confere o discurso de origem, ajusta título/descrição, baixa, compartilha e publica/despublica — sem refazer o corte. O C167 já deixou `speechCut` persistido, `/corte/<id>` no ar e o kit de compartilhamento; este item é a **biblioteca** que faltava para o corte não morrer no link.
- **O que NÃO negociar:** rota separada `/campanha/comunicacao/acervo/cortes` (lista) e `.../cortes/[id]` (detalhe); `communicator`/`coordinator`/`candidate` leem e editam, `advisor`/`leader` fail-closed; mais recentes primeiro com busca por título/descrição; kill switch despublicar/republicar no MESMO link; sem editor de vídeo, sem deletar pela UI, sem métricas; editar o texto do corte nunca toca a fala original nem o vídeo.
- **O que reavaliar:** o C167 só tem a página pública e o card de sessão; **não existe lista, detalhe interno, nem a action que alterna `status`**. O `<video>` público (`(frontend)/corte/[id]/page.tsx:135-144`) e o `SpeechCutShareActions` são os ativos reusáveis; o resto é novo. Decidir busca sem migração, dono da mutação, semântica do toggle e degradação quando `speech` é `null`.
## Abordagem recomendada
```mermaid
flowchart TD
A[assessoria: /campanha/comunicacao/acervo/cortes] --> B[requireCampaignPageActor gate speechCatalog]
B --> C[loadSpeechCutAcervoPageData: find speechCut depth 1 speech, where q, sort -createdAt]
C --> D[SpeechCutLibraryList: card com status + origem + duracao + criado em]
D --> E[detalhe /cortes/id: loadSpeechCutDetailPageData depth 1 speech+media]
E --> F[player video + origem + editor inline + acoes]
F -->|POST /cortes/id| G[updateSpeechCutTextForActor]
F -->|POST /cortes/id/publicacao| H[setSpeechCutPublishedForActor]
G --> I[payload.update overrideAccess:false user:actor]
H --> I
I --> J[afterChange revalidateDocumentById -> /corte/id reflete]
```
**Opções consideradas:** A — biblioteca como lista/detalhe próprios na vertical, reusando shells do acervo + kit do C167 (dono único de mutação) | B — estender o acervo de falas com abas Falas|Cortes | C — não ter UI interna e só expor os cortes no admin Payload.
**Recomendação:** A — o C167 já entregou o objeto (`speechCut`) e a página pública; falta a superfície de leitura/edição. Os shells (`CampaignPageShell`, `CampaignListPendingBoundary`, `CampaignListFooter`, `CampaignListEmptyState`), o `SpeechCutShareActions` e o `campaignJsonMutationRoute` já existem e são profundos; a lista de falas dá o esqueleto exato a copiar. Abas (B) misturam `where`/ordenação/filtros que a própria intenção declarou diferentes; admin (C) não atende o job (a assessoria não vive no admin).
**Rejeitadas:** B porque `SpeechListState` é facetado e acoplado a `Speech.searchText`+segmentos (`speechListFilters.ts:48-55`) — reusá-lo arrastaria busca de fala para dentro da lista de cortes; C porque não é a persona nem o produto (`communicator` não tem admin), e o kill switch/edição de texto precisam viver na vertical.
### Decisões de engenharia (caras de reverter)
**D1 — Busca por título/descrição.**
Opções: A) `or: [{ title: { like } }, { description: { like } }]`, sem migração | B) campo `searchText` + índice + `pnpm migrate:create` | C) filtro client-side.
**Recomendação: A** — os dois campos são `text`/`textarea` curtos (`SpeechCut.ts:101-113`) e o volume é de dezenas/centenas; `like` (ILIKE, case-insensitive) é precedente direto em `contactListUrl.ts:250-259`. Não há índice em `title`/`description` hoje (migration `20260915_122356_add_speech_cut.ts`), mas o volume não justifica um. **Gatilho de revisão:** passar de ~5–10 mil cortes OU o produto pedir busca acento-insensível/ranqueada → aí sim `searchText`+índice+migração, como `Speech`. Limitação honesta registrada: `like` não ignora acento.
**Alternativas rejeitadas:** B porque adiciona campo, hook de normalização e migração (schema é caro de reverter) por um ganho que o volume não paga; C porque busca no cliente quebra paginação e estado canônico na URL.
**D2 — Dono da mutação (título/descrição/status).**
Opções: A) JSON route via `campaignJsonMutationRoute` + server functions em `actions/speech.ts` | B) server action + `formActions.ts` (`runCampaignFormAction`) | C) browser chamando REST `/api/speechCut`.
**Recomendação: A** — `actions/speech.ts` já é o dono das mutações do acervo (C163–C167) e `campaignJsonMutationRoute` é o wrapper que o guard de convenção exige para `POST` (`campaignJsonMutationRoute.ts:92`; `codebaseConventions.unit.spec.ts` recusa `POST` fora dele). O detalhe é client-driven (edição inline + toggle) e os irmãos (`cortar`, `cortar/status`, `cortar/sugestao`) já são JSON routes; **não existe `formActions.ts` no acervo**.
**Alternativas rejeitadas:** B porque introduz um segundo dono de formulário onde nunca houve, e o detalhe não é um `<form action>` de navegação; C porque, apesar de respeitar os access controls da collection, não repete o gate `speechCatalog` nem a montagem whitelisted do `data` e fura o same-origin do wrapper.
**D3 — Semântica do toggle publicar/despublicar.**
Opções: A) `status: 'published' ↔ 'unpublished'`; `publishedAt` recebe o timestamp do novo publish e é preservado no unpublish | B) zerar `publishedAt` ao despublicar | C) novo estado `archived` (soft delete).
**Recomendação: A** — o link é `/corte/<id>` (`speechCutPublicPath`, `speechCut.ts:144`), id imutável ⇒ republicar restaura o MESMO link sem tocar `media`/`speech`; o kill switch já existe do lado público (`status === 'published'` em `(frontend)/corte/[id]/page.tsx:41`) e do lado do anônimo (`canReadSpeechCut` retorna `{ status: { equals: 'published' } }`, `access/speeches.ts:46`). É update de **uma** collection ⇒ sem transação (a invariante de transação vale para escritas multi-collection). O `afterChange` do C167 (`SpeechCut.ts:59-63`) já chama `revalidateDocumentById('speechCut', doc.id)`, que invalida o cache tag que a página pública lê (`getCachedDocumentById`, `(frontend)/corte/[id]/page.tsx:40`) — o refletir é automático, **sem hook novo e sem migração**. `publishedAt` fica como "última publicação" (readOnly, admin); nenhum consumidor o usa para decidir o kill switch.
**Alternativas rejeitadas:** B porque apaga o último momento de publicação (auditoria barata) sem contrapartida; C porque inventa estado e desfaz o vocabulário do C167 (`unpublished` já é canônico em `speechCut.ts:11-23`).
**D4 — Lista como cards vs tabela.**
Opções: A) cards + shells compartilhados (precedente `SpeechResultCard`/`SpeechResultList`) | B) `CampaignTable` (colunas como dado + cookie de visibilidade) | C) lista própria sem shells.
**Recomendação: A** — herda o esqueleto exato de `acervo/page.tsx:29-85` (`CampaignPageShell > CampaignListPendingBoundary > Filters + CampaignListResults > List/EmptyState/Footer`); status + origem + duração + criado em e as ações (abrir/copiar) cabem no card, e só há ordenação fixa `-createdAt` — tabela não paga o custo de `readCampaignColumnVisibility`/cookie nem semântica de sort por coluna.
**Alternativas rejeitadas:** B porque traz o sistema de colunas configuráveis sem necessidade; C porque duplicaria o contrato de pending/footer/empty.
**D5 — Detalhe: player, edição e aviso.**
Opções: A) reusar `SpeechCutShareActions` + `<video controls>` + editor inline client + `AlertDialog` | B) reusar `SpeechDetailPlayer`/`SpeechCutDialog` | C) editar em modal.
**Recomendação: A** — `SpeechCutShareActions.tsx:29` já entrega copiar/WhatsApp/baixar com a prop `primary` (no detalhe, `primary="copy"` + botão "Abrir página pública"); o player do corte é o `<video controls preload="metadata" playsInline>` de `(frontend)/corte/[id]/page.tsx:135-144` (o corte é o MP4 inteiro, não um VOD com offset). O editor de título/descrição é um client component que chama o JSON route; o aviso de despublicar é `AlertDialog` (primitivos em `src/components/ui/AlertDialog.tsx`; precedente de confirmação em `RemoveSupporterDataButton.tsx`) com a copy literal. **Nuance de produto que o detalhe assume:** para corte `unpublished`, copiar/WhatsApp ficam indisponíveis (o link está morto) e mostra-se o CTA de publicar; o download do MP4 continua (o arquivo existe). Para `processing`/`failed`, o editor e o toggle ficam desabilitados (não há o que publicar).
**Alternativas rejeitadas:** B porque `SpeechDetailPlayer` é da fala (VOD/YouTube/transcrição/segmentos/`SpeechCutDialog`) e `SpeechCutDialog` é o fluxo de criação, não de edição; C porque modal para texto longo piora a edição e contraria o draft (cena 3 é edição inline).
**D6 — Discurso de origem.**
Opções: A) carregar `speech` (depth 1) com `overrideAccess:false` e degradar quando `null` | B) query separada com bypass admin | C) denormalizar tipo/data no corte (migração).
**Recomendação: A** — o relationship é `index` e o loader do C167 já lê `speech` a depth 1 (`actions/speech.ts:104-119`); a FK é `SET NULL` (comentário em `SpeechCut.ts:78-80`), logo `speech == null` é esperado: esconde-se o link e mostra "Fala de origem indisponível", mantendo título, descrição, duração e arquivo (que vêm do próprio corte). O link "Ver fala no acervo" aponta para `/campanha/comunicacao/acervo/<speechId>`; a superfície pública continua `/corte/<id>`.
**Alternativas rejeitadas:** B porque bypass admin para ler uma fala que o ator já tem direito (`speechCatalog`) é bypass desnecessário; C porque copia dado que muda (tipo/data da fala) e exige migração por um ganho de render.
**D7 — Access.**
Opções: A) reusar `canReadSpeechCut` (read) e `canReadSpeech` (update) + gate `speechCatalog` na página/actions; `delete` segue `payloadAdminOnly` | B) access novo para o corte | C) update restrito a `unrestricted`.
**Recomendação: A** — é a matriz que a intenção fixa. A collection já está correta (`SpeechCut.ts:51-56`) e **não muda**. A página usa `requireCampaignPageActor({ gate: 'speechCatalog' })` (`campaignPageActor.ts:74`, = `canReadSpeechCatalog`) e cada action repete `canReadSpeechCatalog(actor.role)` antes de ler/escrever, como `actions/speech.ts:247`. O `payload.update` roda `overrideAccess:false, user:actor` e o `data` é montado **só** com `{ title, description }` ou `{ status, publishedAt }` do schema zod — a whitelist de campos é a fronteira, não o access da collection. Atenção explícita: **`canUpdateSpeech` NÃO serve** — é de papéis unrestricted (`access/speeches.ts:27-31`) e barraria o `communicator`, que é o dono do texto do CORTE.
**Alternativas rejeitadas:** B porque duplicaria a matriz que `canReadSpeech`/`canReadSpeechCut` já são donos; C porque a intenção assumiu "qualquer um com acesso ao acervo edita" (validado no gate).
**D8 — Testes e manifest.**
Opções: A) unit de URL + int de access/update/toggle + e2e do gate das rotas internas | B) só int | C) e2e de browser para o toggle.
**Recomendação: A** — unit cobre parse/href canônicos do `speechCutListUrl` e a montagem do `data` do toggle; int cobre a matriz de papéis (padrão de `tests/int/speechCut.int.spec.ts:139-148`), que editar texto reflete no loader e que despublicar remove o corte do find anônimo; e2e HTTP (`campaignSpeechCut.e2e.spec.ts:188-245`) cobre o gate das rotas internas (communicator 200 / advisor+leader fail-closed) sem dirigir ffmpeg. **O manifest não muda** desde que os utilitários fiquem em `src/utilities/speech/` e os componentes em `src/components/campaign/speech/`: os prefixos já estão mapeados em `scripts/lib/e2e-affected-manifest.mjs:288-308` (`${CAMPAIGN_APP}/comunicacao`, `src/components/campaign/speech`, `src/utilities/speech`).
**Alternativas rejeitadas:** B porque a URL/estado canônico é contrato puro e barato de testar; C porque o toggle exige semear corte+media e browser sem ganho sobre o int (o que o e2e do C167 já prova é HTTP, não browser).
**D9 — Nav/chrome/paths.**
Opções: A) sem item de nav novo; entrada pelo acervo de falas + breadcrumb; paths/catálogo atualizados | B) item "Cortes" no nav do communicator | C) abas Falas|Cortes.
**Recomendação: A** — a intenção já assumiu rota separada e o draft (cenas 1–5) mostra a sidebar só com "Comunicação" e o breadcrumb "← Acervo de falas". Adiciona-se `CAMPAIGN_COMMUNICATION_CORTES` em `src/lib/campaignPaths.ts:26` e uma entrada `cortes` no catálogo de chrome + regra exata de lista **antes** da regex genérica `^/campanha/comunicacao/acervo/[^/]+$` (`campaignPageChrome.ts:282-288`), que hoje capturaria `/acervo/cortes` e devolveria `null`; o detalhe usa `SetCampaignPageChrome` como o detalhe da fala. Entrada visível: CTA "Biblioteca de cortes" na lista de falas.
**Alternativas rejeitadas:** B porque o communicator tem um único destino (a vertical) e o acervo é a porta; C já rejeitada em produto (estado/filtros/ordenação diferentes).
### Componentes / mudanças
**Constantes, chrome e nav (sem nav novo):**
- `src/lib/campaignPaths.ts:26` — `CAMPAIGN_COMMUNICATION_CORTES = '/campanha/comunicacao/acervo/cortes'`.
- `src/lib/campaignPageChrome.ts:119-122` — catálogo `cortes` (`{ title: 'Cortes', subtitle: 'O que já foi cortado — reencontre, ajuste o texto e republique.' }`); regras em `:282-288`: exata de lista antes da genérica e `/cortes/[^/]+` → `null`.
- `src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx` — CTA "Biblioteca de cortes" (link, `min-h-11`) apontando para `CAMPAIGN_COMMUNICATION_CORTES`.
- `src/components/campaign/shell/nav.ts` — **sem mudança** (`isCampaignNavActive` cobre o prefixo `/campanha/comunicacao`, `nav.ts:126`).
**Domínio (utilities/server), ao lado do C167 — sem twinning:**
- `src/utilities/speech/speechCutListUrl.ts` — `speechCutPageSize = 25`; `SpeechCutListState = { page: number; q?: string }`; param set `['q','page']`; `parseSpeechCutListParams`, `serializeCanonicalSpeechCutListSearchParams`, `buildSpeechCutListHref`, `buildSpeechCutFiltersKey`, `resolveSpeechCutListUrl` (base `CAMPAIGN_COMMUNICATION_CORTES`) sobre os helpers de `campaignListUrl.ts` (`resolveListUrl:117`, `buildListHref:144`); `buildSpeechCutListWhere` com o `or` de `like`.
- `src/utilities/speech/speechCutPageData.ts` (`server-only`) — `SpeechCutNotFoundError` via `createEntityNotFoundError`; `loadSpeechCutAcervoPageData` (`depth: 1`, select sem `media`, `sort: '-createdAt'`, `page`, `where`, `user`, `overrideAccess:false`) e `loadSpeechCutDetailPageData` (`depth: 1`, com `media`+`speech`); VMs de lista/detalhe mapeando `createdAt`/origem sobre `toSpeechCutViewModel` (`speechCut.ts:210`).
- `src/lib/schemas/speechCut.ts` — `speechCutTextUpdateRequestSchema` (`{ cutId, title, description }` com os máximos já existentes) e `speechCutPublicationRequestSchema` (`{ cutId, published: boolean }`), + `SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE`.
- `src/app/(campaign)/campanha/actions/speech.ts` — `updateSpeechCutTextForActor` e `setSpeechCutPublishedForActor` reusando `loadSpeechCutForActor`/gate (`:104-119,247`).
**Rotas e componentes:**
- `.../acervo/cortes/page.tsx` (lista), `loading.tsx`, `.../cortes/[id]/page.tsx`, `.../cortes/[id]/not-found.tsx`, `.../cortes/[id]/route.ts` (update texto) e `.../cortes/[id]/publicacao/route.ts` (toggle) + `types.ts`, ambos `POST` via `campaignJsonMutationRoute`.
- `src/components/campaign/speech/SpeechCutLibraryFilters.tsx` (client; `CampaignListOmnibox` query-only sobre `useCampaignListFilterNavigation`), `SpeechCutLibraryList.tsx`, `SpeechCutLibraryCard.tsx` (status chip, origem, duração, criado em, ações), `SpeechCutTextEditor.tsx` (client, inline), `SpeechCutPublicationPanel.tsx` (client, status + `AlertDialog` de despublicar + publicar), `SpeechCutOriginCard.tsx` (client-safe).
**Mapeamento das cenas do draft (`c168-biblioteca-cortes-ui-draft.html`):**
| Cena | Conteúdo | Componente |
| ------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| 1 lista desktop | título + sub + busca + cards + paginação | `cortes/page.tsx`, `SpeechCutLibraryFilters`, `SpeechCutLibraryList/Card`, `CampaignListFooter` |
| 2 lista vazia | "Nenhum corte ainda" + ir ao acervo de falas | `CampaignListEmptyState` com CTA para `CAMPAIGN_COMMUNICATION_ACERVO` |
| 3 detalhe | player, baixar/abrir/copiar/WhatsApp, editor título/descrição, origem, publicação | `cortes/[id]/page.tsx`, `<video>`, `SpeechCutShareActions`, `SpeechCutTextEditor`, `SpeechCutOriginCard`, `SpeechCutPublicationPanel` |
| 4 confirmação despublicar | título/corpo/botões literais | `SpeechCutPublicationPanel` + `AlertDialog` |
| 5 mobile | lista e detalhe empilhados | mesmos componentes, classes responsivas |
### Dados → forma
- **Fonte:** collection `speechCut`, `depth: 1` para `speech` (lista) e `speech`+`media` (detalhe); `where` derivado de `q`; `sort: '-createdAt'`; `limit: 25`; `page` canônica da URL. Sem agregação, sem gráfico, sem KPI.
- **Lista (DTO):** `id`, `title`, `status` + label, `createdAtLabel`, `durationLabel`, `publicPath`, `speech: { id, typeLabel, dateLabel, href } | null`, `excerpt` (título/descrição para a busca).
- **Detalhe (DTO):** tudo da lista + `description`, `startSeconds`/`endSeconds` (span "00:43–01:55"), `mediaUrl`/`mediaFilename` (download), `youtubeVideoId` (capa), `publishedAt`.
- **Estado vazio:** sem `q` → "Nenhum corte ainda" + CTA ao acervo de falas; com `q` → "Nenhum corte encontrado para ...".
## Fases verificáveis
1. **URL + navegação:** `CAMPAIGN_COMMUNICATION_CORTES`, catálogo/regras de chrome, CTA no acervo, `speechCutListUrl.ts`. Prova: unit de parse/href/filtersKey + chrome; `pnpm gate:fast`.
2. **Dados e access:** `speechCutPageData.ts` + `loadSpeechCut*`; int da matriz de papéis (communicator/coordinator/candidate leem; advisor/leader fail-closed), busca por título/descrição e paginação. Prova: `pnpm test:int` + `pnpm gate:fast`.
3. **Lista:** `cortes/page.tsx` + filters + list/card + empty + footer + `loading.tsx`. Prova: gate verde + smoke local na rota.
4. **Detalhe e mutações:** schemas, actions, duas rotas JSON, player, editor, origem, painel de publicação, not-found. Prova: int de update de texto (reflete no loader) e do toggle (published→unpublished some do find anônimo; republicar restaura `/corte/<id>`); `pnpm gate:fast`.
5. **E2E + polimento:** gate HTTP das rotas internas em `campaignSpeechCut.e2e.spec.ts`; Impeccable C (shape→craft→critique→polish) sobre as 5 cenas; changelog `docs/changelog/2026-09-15-c168.md`; `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- **Editor de vídeo (início/fim/re-render):** o vídeo é imutável; nada de tocar `media`, `startSeconds`, `endSeconds`.
- **`searchText`/índice/migração:** só com o gatilho da D1; nada de migração nesta fatia.
- **Tabela com colunas configuráveis/cookie de visibilidade:** não; só `-createdAt`.
- **Segundo acervo de falas dentro do detalhe:** não carregar segmentos/transcrição; o player do corte é `<video>`, sem `SpeechDetailPlayer`/`canUpdateSpeech`.
- **Filtros facetados (ano/tema/município) na lista de cortes:** não; só `q`+`page`.
- **Ações em lote/exportação, coleções/kits, favoritos, métricas (views/downloads), comentários, versionamento de texto:** não.
- **Deletar/arquivar pela UI:** não; `delete` segue `payloadAdminOnly`.
- **Preview Open Graph no detalhe interno:** não neste item; a capa do YouTube fica no card, se houver.
## Débitos triados (simplify 2026-09-15)
- **Já resolvido no simplify (não reabrir):** loader do corte duplicado (action C167 × loader da biblioteca) → `findSpeechCutForActor` único em `utilities/speech/speechCutData.ts`; botão de copiar clonado → `CopyLinkButton` compartilhado (`src/components/CopyLinkButton.tsx`); casts `as SpeechCutRecordForView`/`as { id?: unknown }` removidos (`CutSpeechRecord.id?: number`); `hasFilters` agora é `Boolean(state.q)` (não o page); `postCampaignJson` do editor e do painel com `try/finally`; copy de despublicar numa constante; `clearSearchOnlyOmnibox.state` opcional; regra de chrome redundante do detalhe removida; `canonicalUrl`/fallback inalcançável simplificados.
- **Explicitamente fora / defer com gatilho:**
- **`SpeechCutPlayer` duplica o `<video>` da página pública** — defer; gatilho: uma 3ª superfície tocar o MP4 do corte (aí extrai um `CutVideo` compartilhado).
- **Hook de URL absoluta** (`useAbsolutePublicUrl`) repetido entre `SpeechCutResultCard` e `SpeechCutLibraryShareActions` — defer; gatilho: uma 3ª superfície precisar do `origin` no render (hoje o `<a>` do WhatsApp exige o valor no render, não no clique).
- **`SpeechCutLibraryList` pass-through** — descartado; espelha de propósito o `SpeechResultList` do acervo de falas (consistência da vertical).
- **`SpeechCutStatusBadge` com union local de variantes do `Badge`** — descartado; o union local é type-honest e evita arrastar `cva`/`badgeVariants` para o componente.
- **`saved` do editor não ressincroniza em refresh externo** — descartado; edição é de equipe pequena e o `router.refresh()` pós-salvar casa props e estado.
- **`withPageReset` no omnibox** e **`formatBahiaCivilDate` + `formatSpeechDate`** — descartados (custo maior que o ganho; pinagem dupla é defensiva e a data civil é testada).
- Nenhum achado restante com score ≥3 / `expensive_lock` → **nenhuma Issue nova** registrada neste ciclo.
## Riscos e mitigação
- **`communicator` lê mas não atualiza a fala:** o editor é do CORTE e o update usa `canReadSpeech`, nunca `canUpdateSpeech`; o `data` é whitelisted pelo zod.
- **`speech` nulo (FK `SET NULL`):** loader degrada sem quebrar; teste int cobre corte sem fala.
- **Ordem das regras de chrome:** a regra exata `/acervo/cortes` entra **antes** da regex genérica; teste de unidade do chrome.
- **Kill switch não refletir:** coberto pelo `afterChange → revalidateDocumentById` existente; int/e2e provam 404 imediato do link.
- **Link público sujo:** contrato `/corte/<id>` intocado; só `status`/`title`/`description` mudam.
- **Busca com acento:** limitação documentada (D1) + gatilho claro.
- **Manifest/e2e:** manter arquivos nos prefixos já mapeados; se um prefixo novo surgir, atualizar `e2e-affected-manifest.mjs` e o conjunto curado.
- **UI do C167 acoplada:** não reusar `SpeechCutResultCard` (hardcoda "Corte publicado", é session-only); a biblioteca tem card por status.
## Aceite de engenharia
| Aceite (intenção) | Evidência |
| ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `/campanha/comunicacao/acervo/cortes`, recentes primeiro, título/origem+link/duração/status/criado em, busca, paginação | page + `loadSpeechCutAcervoPageData`; unit da URL; int da busca; smoke/e2e |
| Detalhe com player, origem com link, título/descrição editáveis; salvar reflete no público | `cortes/[id]` + `updateSpeechCutTextForActor`; int do update; cache já revalidado pelo `afterChange` |
| Baixar MP4, copiar link, WhatsApp; link = `/corte/<id>` | `SpeechCutShareActions` + `mediaUrl`; e2e do link público |
| Publicar/despublicar com aviso e estado na lista | `SpeechCutPublicationPanel` + `AlertDialog` (copy literal); int do toggle; chip na lista |
| Vazio "Nenhum corte ainda" + caminho ao acervo de falas | `CampaignListEmptyState` com CTA |
| Kill switch; republicar restaura o MESMO link | `status` + id imutável; int published→unpublished→published |
| `communicator`/`coordinator`/`candidate` editam; `advisor`/`leader` negados | gate na página/actions + int/e2e da matriz |
| Sem deletar/métricas/editor; editar não toca a fala nem o vídeo | escopo e data whitelisted; sem mudança de collection/migration |
| Qualidade | `pnpm gate:fast` verde + `pnpm push`; changelog `docs/changelog/2026-09-15-c168.md` |
Invariantes respeitadas: Local API sempre com `user` + `overrideAccess:false`; toggle/edição são single-collection (sem transação — se virar multi, usar `withPayloadTransaction` + `req:{transactionID}`); sem collection paralela nem Consent; `leader`/`advisor` lockdown intocado; copy pt-BR e identificadores em inglês; **nenhuma migration** (e, se houver, só via `pnpm migrate:create`); dependências `lib → utilities → components → app`.
## Self-score (decision-quality ≥4)
1. **Decisões caras com rejeitadas — 5/5:** D1–D9 têm `Opções/Recomendação/Alternativas rejeitadas`, incluindo o que não se pode reverter por engano (busca/schema, access, semântica do toggle, link público).
2. **Cabe no appetite — 4/5:** reuso de shells, `SpeechCutShareActions`, `campaignJsonMutationRoute` e dos helpers de `campaignListUrl` mantém ~1–1,5 dia; o custo real é a UI Impeccable C de 5 cenas, que a fase 5 absorve.
3. **Rabbit holes nomeados — 5/5:** editor de vídeo, `searchText`/migração, tabela, segundo acervo, filtros facetados, lote/métricas e delete estão explicitamente fora, com gatilho onde aplicável.
4. **Depth check reusa shells — 5/5:** nada de pass-through raso: usa `campaignPageActor`, `campaignJsonMutationRoute`, `campaignListUrl`, `createEntityNotFoundError`, `SpeechCutShareActions`, o `<video>` público e o `afterChange` existente; as utilities vão para `src/utilities/speech/` (dono do C167), sem `src/utilities/speechCut/` gêmeo.
5. **Intenção de produto satisfeita — 5/5:** cada aceite literal tem evidência e a persona/gate fecham fail-closed; o texto do corte e o vídeo permanecem os únicos mutáveis.

View file

@ -0,0 +1,29 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/Empty'
import { CAMPAIGN_COMMUNICATION_CORTES } from '@/lib/campaignPaths'
export default function SpeechCutNotFoundPage() {
return (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyTitle>Corte não encontrado</EmptyTitle>
<EmptyDescription>
O corte pode ter sido removido ou você não tem acesso a ele.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href={CAMPAIGN_COMMUNICATION_CORTES}>Voltar à biblioteca de cortes</Link>
</Button>
</EmptyContent>
</Empty>
)
}

View file

@ -0,0 +1,109 @@
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 { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { SpeechCutLibraryShareActions } from '@/components/campaign/speech/SpeechCutLibraryShareActions'
import { SpeechCutOriginCard } from '@/components/campaign/speech/SpeechCutOriginCard'
import { SpeechCutPlayer } from '@/components/campaign/speech/SpeechCutPlayer'
import { SpeechCutPublicationPanel } from '@/components/campaign/speech/SpeechCutPublicationPanel'
import { SpeechCutTextEditor } from '@/components/campaign/speech/SpeechCutTextEditor'
import { Button } from '@/components/ui/button'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { CAMPAIGN_COMMUNICATION_CORTES } from '@/lib/campaignPaths'
import { strictDecimalInteger } from '@/utilities/campaignListUrl'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
loadSpeechCutDetailPageData,
SpeechCutNotFoundError,
} from '@/utilities/speech/speechCutPageData'
type SpeechCutDetailPageProps = {
params: Promise<{ id: string }>
}
export async function generateMetadata({ params }: SpeechCutDetailPageProps) {
const { id } = await params
const cutId = strictDecimalInteger(id)
if (!cutId) return campaignPageMetadata({ title: 'Corte' })
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'speechCatalog' }),
getPayload({ config }),
])
try {
const cut = await loadSpeechCutDetailPageData(payload, user, cutId)
return campaignPageMetadata({ title: cut.title })
} catch {
return campaignPageMetadata({ title: 'Corte' })
}
}
/** C168 — one cut: player, origin speech, editable text and the kill switch. */
export default async function SpeechCutDetailPage({ params }: SpeechCutDetailPageProps) {
const [{ id }, user, payload] = await Promise.all([
params,
requireCampaignPageActor({ gate: 'speechCatalog' }),
getPayload({ config }),
])
const cutId = strictDecimalInteger(id)
if (!cutId) notFound()
const cut = await loadSpeechCutDetailPageData(payload, user, cutId).catch((error) => {
if (error instanceof SpeechCutNotFoundError) notFound()
throw error
})
const notReady = cut.status === 'processing' || cut.status === 'failed'
return (
<CampaignPageShell>
<SetCampaignPageChrome chrome={{ title: cut.title }} />
<div className="flex flex-col gap-4">
<Button asChild variant="ghost" className="-ml-2 min-h-10 self-start">
<Link href={CAMPAIGN_COMMUNICATION_CORTES}>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar aos cortes
</Link>
</Button>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="min-w-0">
<h1 className="text-lg font-medium">{cut.title}</h1>
<p className="mt-1 text-xs text-muted-foreground">
Corte de {cut.durationLabel}
{cut.createdAtLabel ? <> · criado em {cut.createdAtLabel}</> : null}
</p>
<div className="mt-4">
<SpeechCutPlayer cut={cut} />
</div>
<div className="mt-3">
<SpeechCutLibraryShareActions cut={cut} />
</div>
<div className="mt-6">
<SpeechCutTextEditor
cutId={cut.id}
initialTitle={cut.title}
initialDescription={cut.description}
disabled={notReady}
/>
</div>
</div>
<aside className="space-y-4">
<SpeechCutOriginCard cut={cut} />
<SpeechCutPublicationPanel cutId={cut.id} status={cut.status} />
</aside>
</div>
</div>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { setSpeechCutPublishedForActor } from '@/app/(campaign)/campanha/actions/speech'
import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_GENERIC_ERROR_MESSAGE,
SPEECH_CUT_NOT_FOUND_MESSAGE,
SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE,
speechCutPublicationRequestSchema,
} from '@/lib/schemas/speechCut'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { SpeechCutPublicationResponse } from '../types'
export const dynamic = 'force-dynamic'
export const POST = campaignJsonMutationRoute(
{
bodySchema: speechCutPublicationRequestSchema,
safeMessages: [
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_NOT_FOUND_MESSAGE,
SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE,
],
genericMessage: SPEECH_CUT_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const cut = await setSpeechCutPublishedForActor(body)
return NextResponse.json<SpeechCutPublicationResponse>({ status: 'success', cut })
},
)

View file

@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import { updateSpeechCutTextForActor } from '@/app/(campaign)/campanha/actions/speech'
import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_GENERIC_ERROR_MESSAGE,
SPEECH_CUT_NOT_FOUND_MESSAGE,
speechCutTextUpdateRequestSchema,
} from '@/lib/schemas/speechCut'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { SpeechCutTextUpdateResponse } from '../types'
export const dynamic = 'force-dynamic'
export const POST = campaignJsonMutationRoute(
{
bodySchema: speechCutTextUpdateRequestSchema,
safeMessages: [SPEECH_CUT_FORBIDDEN_MESSAGE, SPEECH_CUT_NOT_FOUND_MESSAGE],
genericMessage: SPEECH_CUT_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const cut = await updateSpeechCutTextForActor(body)
return NextResponse.json<SpeechCutTextUpdateResponse>({ status: 'success', cut })
},
)

View file

@ -0,0 +1,11 @@
import type { SpeechCutViewModel } from '@/lib/speechCut'
/** Wire contract of `POST .../cortes/[id]/texto` (C168) — edit title/description. */
export type SpeechCutTextUpdateResponse =
| { status: 'success'; cut: SpeechCutViewModel }
| { status: 'error'; message: string }
/** Wire contract of `POST .../cortes/[id]/publicacao` (C168) — kill switch. */
export type SpeechCutPublicationResponse =
| { status: 'success'; cut: SpeechCutViewModel }
| { status: 'error'; message: string }

View file

@ -0,0 +1,19 @@
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Skeleton } from '@/components/ui/skeleton'
/** List-shaped skeleton for the cut library and its detail route. */
export default function SpeechCutLibraryLoading() {
return (
<CampaignPageShell aria-label="Carregando biblioteca de cortes" aria-busy="true">
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-5 w-80 max-w-full" />
</div>
<div className="flex flex-col gap-3">
<Skeleton className="h-11 w-full" />
<Skeleton className="h-32 w-full rounded-xl" />
<Skeleton className="h-32 w-full rounded-xl" />
</div>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,90 @@
import config from '@payload-config'
import { ScissorsIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { SpeechCutLibraryFilters } from '@/components/campaign/speech/SpeechCutLibraryFilters'
import { SpeechCutLibraryList } from '@/components/campaign/speech/SpeechCutLibraryList'
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 {
buildSpeechCutFiltersKey,
buildSpeechCutListHref,
} from '@/utilities/speech/speechCutListUrl'
import { loadSpeechCutAcervoPageData } from '@/utilities/speech/speechCutPageData'
export const metadata = campaignPageMetadataFromCatalog('cortes')
type SpeechCutLibraryPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
/** C168 — the cut library: what was already cut, newest first, searchable. */
export default async function SpeechCutLibraryPage({ searchParams }: SpeechCutLibraryPageProps) {
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'speechCatalog' }),
getPayload({ config }),
])
const data = await loadSpeechCutAcervoPageData(payload, user, searchParams)
if (data.redirectHref) redirect(data.redirectHref)
const hasFilters = Boolean(data.state.q)
return (
<CampaignPageShell aria-label="Biblioteca de cortes">
<CampaignListPendingBoundary>
<SpeechCutLibraryFilters key={buildSpeechCutFiltersKey(data.state)} state={data.state} />
<CampaignListResults>
{data.rows.length > 0 ? (
<SpeechCutLibraryList rows={data.rows} />
) : (
<CampaignListEmptyState
icon={hasFilters ? SearchXIcon : ScissorsIcon}
title={
data.state.q
? `Nenhum corte encontrado para "${data.state.q}"`
: 'Nenhum corte ainda'
}
description={
hasFilters
? 'Tente outro termo ou limpe a busca.'
: 'Quando você cortar um trecho de uma fala, ele fica guardado aqui para reencontrar, baixar, ajustar o texto e republicar.'
}
>
<Button asChild variant="outline" className="min-h-11">
<Link
href={hasFilters ? CAMPAIGN_COMMUNICATION_CORTES : CAMPAIGN_COMMUNICATION_ACERVO}
>
{hasFilters ? 'Limpar busca' : 'Ir para o acervo de falas'}
</Link>
</Button>
</CampaignListEmptyState>
)}
{data.rows.length > 0 ? (
<CampaignListFooter
totalDocs={data.totalDocs}
singular="corte encontrado"
plural="cortes encontrados"
page={data.state.page}
totalPages={data.totalPages}
hrefForPage={(page) => buildSpeechCutListHref(data.state, page)}
/>
) : null}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -1,5 +1,5 @@
import config from '@payload-config'
import { SearchXIcon } from 'lucide-react'
import { ScissorsIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
@ -15,7 +15,7 @@ import { SpeechAcervoFilters } from '@/components/campaign/speech/SpeechAcervoFi
import { SpeechResultList } from '@/components/campaign/speech/SpeechResultList'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { CAMPAIGN_COMMUNICATION_ACERVO, CAMPAIGN_COMMUNICATION_CORTES } from '@/lib/campaignPaths'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { buildSpeechFiltersKey, buildSpeechListHref } from '@/utilities/speech/speechListUrl'
import { loadSpeechAcervoPageData } from '@/utilities/speech/speechPageData'
@ -39,6 +39,15 @@ 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>
<CampaignListPendingBoundary>
<SpeechAcervoFilters
key={buildSpeechFiltersKey(data.state)}

View file

@ -7,12 +7,17 @@ import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_INVALID_RANGE_MESSAGE,
SPEECH_CUT_NOT_FOUND_MESSAGE,
SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE,
SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE,
SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE,
speechCutPublicationRequestSchema,
speechCutRequestSchema,
speechCutStatusRequestSchema,
speechCutSuggestionRequestSchema,
speechCutTextUpdateRequestSchema,
type SpeechCutPublicationRequest,
type SpeechCutRequest,
type SpeechCutTextUpdateRequest,
} from '@/lib/schemas/speechCut'
import {
SPEECH_VOD_FORBIDDEN_MESSAGE,
@ -21,11 +26,7 @@ import {
speechVodRequestSchema,
} from '@/lib/schemas/speechVod'
import { formatSpeechDate } from '@/lib/speechClock'
import {
toSpeechCutViewModel,
type SpeechCutRecordForView,
type SpeechCutViewModel,
} from '@/lib/speechCut'
import { toSpeechCutViewModel, type SpeechCutViewModel } from '@/lib/speechCut'
import {
MAX_EXCERPT_SECONDS,
MIN_EXCERPT_SECONDS,
@ -34,6 +35,7 @@ import {
import { speechVodCoordinates, type SpeechVodResolution } from '@/lib/speechVod'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { findSpeechCutForActor as loadSpeechCutForActor } from '@/utilities/speech/speechCutData'
import { reapStaleSpeechCut } from '@/utilities/speech/speechCutJob'
import {
suggestSpeechCutMetadata,
@ -101,23 +103,6 @@ const speechCutSpeechSelect = {
excerptTMs: true,
} as const
const loadSpeechCutForActor = async (
payload: Payload,
actor: CampaignUser,
cutId: number,
): Promise<SpeechCutRecordForView | null> => {
const result = await payload.find({
collection: 'speechCut',
where: { id: { equals: cutId } },
depth: 1,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
})
return result.docs[0] ?? null
}
const loadSpeechForCut = async (payload: Payload, actor: CampaignUser, speechId: number) => {
const result = await payload.find({
collection: 'speech',
@ -277,6 +262,65 @@ export const getSpeechCutStatusForActor = async (input: {
return toSpeechCutViewModel(cut)
}
// ---------------------------------------------------------------------------
// C168 — the cut library: edit the cut's own text and toggle the public link.
// Same fresh `speechCatalog` gate as the C167 mutations; the collection access
// (canReadSpeech) and the whitelisted zod payload are the field boundary.
// ---------------------------------------------------------------------------
/** Edits the cut's title/description — never the source speech nor the video. */
export const updateSpeechCutTextForActor = async (
input: SpeechCutTextUpdateRequest,
): Promise<SpeechCutViewModel> => {
const parsed = speechCutTextUpdateRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
const current = await loadSpeechCutForActor(payload, actor, parsed.cutId)
if (!current) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
const cut = await payload.update({
collection: 'speechCut',
id: parsed.cutId,
data: { title: parsed.title, description: parsed.description },
depth: 1,
user: actor,
overrideAccess: false,
})
return toSpeechCutViewModel(cut)
}
/**
* Kill switch: `published` restores the same public `/corte/<id>` link
* (`publishedAt` stamps the new publication); `unpublished` only takes it off
* the air — the row, the file and the id stay. Refused until the MP4 exists.
*/
export const setSpeechCutPublishedForActor = async (
input: SpeechCutPublicationRequest,
): Promise<SpeechCutViewModel> => {
const parsed = speechCutPublicationRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
const current = await loadSpeechCutForActor(payload, actor, parsed.cutId)
if (!current) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
if (parsed.published && !current.media) throw new Error(SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE)
const cut = await payload.update({
collection: 'speechCut',
id: parsed.cutId,
data: parsed.published
? { status: 'published', publishedAt: new Date().toISOString() }
: { status: 'unpublished' },
depth: 1,
user: actor,
overrideAccess: false,
})
return toSpeechCutViewModel(cut)
}
/** AI suggestion for the picked window; falls back deterministically, never blocks. */
export const suggestSpeechCutMetadataForActor = async (input: {
speechId: number

View file

@ -0,0 +1,47 @@
'use client'
import { CheckIcon, CopyIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { copyFeedbackLabels, copyFeedbackLiveMessages, useCopyFeedback } from '@/lib/copyFeedback'
import { cn } from '@/lib/utils'
type CopyLinkButtonProps = {
/** Absolute URL or a same-origin path — resolved against the current origin on click. */
url: string
variant?: 'default' | 'outline' | 'ghost'
className?: string
}
/**
* The one copy-to-clipboard control of the cut surfaces (public page, result
* card, library): same feedback label/auto-reset and screen-reader live region.
* A relative path is resolved against the current origin on click, so a server
* component can pass `publicPath` directly.
*/
export const CopyLinkButton = ({ url, variant = 'outline', className }: CopyLinkButtonProps) => {
const { feedback, copy } = useCopyFeedback()
return (
<>
<Button
type="button"
variant={variant}
className={cn('min-h-10', className)}
onClick={() => {
void copy(new URL(url, window.location.origin).toString())
}}
>
{feedback === 'copied' ? (
<CheckIcon data-icon="inline-start" aria-hidden="true" />
) : (
<CopyIcon data-icon="inline-start" aria-hidden="true" />
)}
{copyFeedbackLabels[feedback]}
</Button>
<span aria-live="polite" className="sr-only">
{copyFeedbackLiveMessages[feedback]}
</span>
</>
)
}

View file

@ -1,10 +1,10 @@
'use client'
import { CheckIcon, CopyIcon, DownloadIcon } from 'lucide-react'
import { DownloadIcon } from 'lucide-react'
import { CopyLinkButton } from '@/components/CopyLinkButton'
import { WhatsAppIcon } from '@/components/socialIcons'
import { Button } from '@/components/ui/button'
import { copyFeedbackLabels, copyFeedbackLiveMessages, useCopyFeedback } from '@/lib/copyFeedback'
import { buildSpeechCutShare } from '@/lib/speechCut'
import { cn } from '@/lib/utils'
@ -34,7 +34,6 @@ export const SpeechCutShareActions = ({
primary = 'copy',
className,
}: SpeechCutShareActionsProps) => {
const { feedback, copy } = useCopyFeedback()
const share = buildSpeechCutShare({ title, url })
return (
@ -50,19 +49,7 @@ export const SpeechCutShareActions = ({
</a>
</Button>
) : null}
<Button
type="button"
variant={primary === 'copy' ? 'default' : 'outline'}
className="min-h-10"
onClick={() => void copy(url)}
>
{feedback === 'copied' ? (
<CheckIcon data-icon="inline-start" aria-hidden="true" />
) : (
<CopyIcon data-icon="inline-start" aria-hidden="true" />
)}
{copyFeedbackLabels[feedback]}
</Button>
<CopyLinkButton url={url} variant={primary === 'copy' ? 'default' : 'outline'} />
{primary === 'copy' ? (
<Button asChild variant="outline" className="min-h-10">
<a href={share.whatsAppUrl} target="_blank" rel="noopener noreferrer">
@ -79,9 +66,6 @@ export const SpeechCutShareActions = ({
</a>
</Button>
) : null}
<span aria-live="polite" className="sr-only">
{copyFeedbackLiveMessages[feedback]}
</span>
</div>
)
}

View file

@ -0,0 +1,55 @@
import { PlayIcon } from 'lucide-react'
import Link from 'next/link'
import { CopyLinkButton } from '@/components/CopyLinkButton'
import { SpeechCutStatusBadge } from '@/components/campaign/speech/SpeechCutStatusBadge'
import { Button } from '@/components/ui/button'
import { CAMPAIGN_COMMUNICATION_CORTES } from '@/lib/campaignPaths'
import type { SpeechCutLibraryItemViewModel } from '@/lib/speechCut'
/**
* C168 — one row of the cut library: status + creation date, the editable title,
* the origin speech (label + link to the acervo) with the stored duration, and
* the two actions the list needs (open the detail, copy the public link when it
* is live). Download/WhatsApp/publição live in the detail.
*/
export const SpeechCutLibraryCard = ({ cut }: { cut: SpeechCutLibraryItemViewModel }) => (
<article
className={
cut.status === 'published'
? 'rounded-xl border bg-card p-4'
: 'rounded-xl border border-dashed bg-card p-4'
}
>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<SpeechCutStatusBadge status={cut.status} />
{cut.createdAtLabel ? (
<span className="text-xs text-muted-foreground">Criado em {cut.createdAtLabel}</span>
) : null}
</div>
<h3 className="mt-2 text-sm font-medium">{cut.title}</h3>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{cut.origin ? (
<Link href={cut.origin.href} className="underline underline-offset-4 hover:text-foreground">
{cut.origin.label}
</Link>
) : (
<span>Fala de origem indisponível</span>
)}
<span aria-hidden="true">·</span>
<span>{cut.durationLabel}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button asChild variant="outline" className="min-h-10">
<Link href={`${CAMPAIGN_COMMUNICATION_CORTES}/${cut.id}`}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
Abrir corte
</Link>
</Button>
{cut.status === 'published' ? <CopyLinkButton url={cut.publicPath} variant="ghost" /> : null}
</div>
</article>
)

View file

@ -0,0 +1,88 @@
'use client'
import { useMemo, useState } from 'react'
import {
CampaignListOmnibox,
campaignListOmniboxFormClassName,
} from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import {
applySearchOnlyOmniboxSuggestion,
buildSearchOnlyOmniboxChips,
buildSearchOnlyOmniboxSuggestions,
clearSearchOnlyOmnibox,
removeSearchOnlyOmniboxChip,
type SearchOnlyOmniboxAction,
} from '@/lib/searchOnlyListOmnibox'
import {
buildSpeechCutListHref,
type SpeechCutListState,
} from '@/utilities/speech/speechCutListUrl'
/**
* C168 — the cut library only exposes free-text search over title/description
* (no facets): the degenerate omnibox of `searchOnlyListOmnibox`, the same
* chassis the other search-only lists use.
*/
export const SpeechCutLibraryFilters = ({ state }: { state: SpeechCutListState }) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => buildSpeechCutListHref(next, 1),
})
const [query, setQuery] = useState('')
const withPageReset = (next: SpeechCutListState): SpeechCutListState => ({ ...next, page: 1 })
const chips = useMemo(() => buildSearchOnlyOmniboxChips(state), [state])
const suggestions = useMemo(() => buildSearchOnlyOmniboxSuggestions(query), [query])
const runAction = (action: SearchOnlyOmniboxAction<SpeechCutListState>) => {
if (action.kind === 'clear') setQuery('')
navigate(action.state)
}
return (
<form
role="search"
className={campaignListOmniboxFormClassName}
onSubmit={(event) => {
event.preventDefault()
}}
>
<CampaignListOmnibox
id="speech-cut-omnibox"
label="Buscar corte por título ou descrição"
placeholder="Buscar por título ou descrição…"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(
applySearchOnlyOmniboxSuggestion({
state,
suggestionId,
withPageReset,
}),
)
}}
onCommitQuery={(text) => {
runAction(
applySearchOnlyOmniboxSuggestion({
state,
suggestionId: `q:${text}`,
withPageReset,
}),
)
}}
onRemoveChip={(chipId) => {
runAction(removeSearchOnlyOmniboxChip({ state, chipId, withPageReset }))
}}
onClearAll={() => {
runAction(clearSearchOnlyOmnibox({ cleared: { page: 1 } }))
}}
/>
</form>
)
}

View file

@ -0,0 +1,14 @@
import { SpeechCutLibraryCard } from '@/components/campaign/speech/SpeechCutLibraryCard'
import type { SpeechCutLibraryItemViewModel } from '@/lib/speechCut'
export const SpeechCutLibraryList = ({
rows,
}: {
rows: readonly SpeechCutLibraryItemViewModel[]
}) => (
<div className="flex flex-col gap-3">
{rows.map((cut) => (
<SpeechCutLibraryCard key={cut.id} cut={cut} />
))}
</div>
)

View file

@ -0,0 +1,65 @@
'use client'
import { DownloadIcon, ExternalLinkIcon } from 'lucide-react'
import { useEffect, useState } from 'react'
import { SpeechCutShareActions } from '@/components/SpeechCutShareActions'
import { Button } from '@/components/ui/button'
import type { SpeechCutLibraryItemViewModel } from '@/lib/speechCut'
/**
* C168 — the detail's actions. A published cut shares the public `/corte/<id>`
* link (copy/WhatsApp/download) and opens it; an unpublished one only offers
* the stored MP4, since the link is intentionally dead until republished.
*/
export const SpeechCutLibraryShareActions = ({ cut }: { cut: SpeechCutLibraryItemViewModel }) => {
const [absoluteUrl, setAbsoluteUrl] = useState('')
useEffect(() => {
setAbsoluteUrl(new URL(cut.publicPath, window.location.origin).toString())
}, [cut.publicPath])
if (cut.status === 'published') {
return (
<div className="flex flex-col gap-2">
<SpeechCutShareActions
url={absoluteUrl || cut.publicPath}
title={cut.title}
downloadUrl={cut.mediaUrl}
downloadFilename={cut.mediaFilename}
primary="copy"
/>
<div>
<Button asChild variant="outline" className="min-h-10">
<a href={cut.publicPath} target="_blank" rel="noopener noreferrer">
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir página pública
</a>
</Button>
</div>
<p className="text-xs text-muted-foreground">
O link compartilhado é o mesmo da página pública:{' '}
<span className="font-mono">{cut.publicPath}</span>
</p>
</div>
)
}
if (!cut.mediaUrl) return null
return (
<div className="flex flex-col gap-2">
<div>
<Button asChild variant="outline" className="min-h-10">
<a href={cut.mediaUrl} download={cut.mediaFilename ?? ''}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Baixar arquivo (MP4)
</a>
</Button>
</div>
<p className="text-xs text-muted-foreground">
O link público está desativado — publique para compartilhar de novo.
</p>
</div>
)
}

View file

@ -0,0 +1,33 @@
import Link from 'next/link'
import { formatSpeechClock } from '@/lib/speechClock'
import type { SpeechCutLibraryItemViewModel } from '@/lib/speechCut'
/**
* C168 — context of the cut: which speech it came from and the stored excerpt
* span, with the link back to the acervo. Degrades honestly when the source
* speech was deleted (the FK is `SET NULL`) — the cut itself stays usable.
*/
export const SpeechCutOriginCard = ({ cut }: { cut: SpeechCutLibraryItemViewModel }) => (
<section className="rounded-xl border p-4">
<h2 className="text-xs tracking-wide text-muted-foreground uppercase">Discurso de origem</h2>
{cut.origin ? (
<>
<p className="mt-1.5 text-sm text-foreground/90">{cut.origin.label}</p>
<p className="mt-1 text-xs text-muted-foreground">
Trecho {formatSpeechClock(cut.startSeconds)}–{formatSpeechClock(cut.endSeconds)} da fala
</p>
<Link
href={cut.origin.href}
className="mt-2 inline-flex min-h-11 items-center text-sm underline underline-offset-4 hover:text-foreground"
>
Ver fala no acervo
</Link>
</>
) : (
<p className="mt-1.5 text-sm text-muted-foreground">
A fala de origem não está mais disponível. O corte continua salvo e compartilhável.
</p>
)}
</section>
)

View file

@ -0,0 +1,30 @@
import type { SpeechCutLibraryItemViewModel } from '@/lib/speechCut'
/**
* C168 — the cut is a plain MP4 (never the YouTube/VOD source): the same
* `<video>` the public page plays, with the session cover as poster when known.
*/
export const SpeechCutPlayer = ({ cut }: { cut: SpeechCutLibraryItemViewModel }) => {
if (!cut.mediaUrl) {
return (
<div className="flex aspect-video w-full items-center justify-center rounded-xl border bg-muted/40 text-sm text-muted-foreground">
O arquivo do corte ainda não está disponível.
</div>
)
}
const poster = cut.youtubeVideoId
? `https://i.ytimg.com/vi/${cut.youtubeVideoId}/hqdefault.jpg`
: undefined
return (
<video
controls
preload="metadata"
playsInline
poster={poster}
src={cut.mediaUrl}
className="aspect-video w-full rounded-xl border bg-black"
/>
)
}

View file

@ -0,0 +1,149 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import type { SpeechCutPublicationResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/types'
import { SpeechCutStatusBadge } from '@/components/campaign/speech/SpeechCutStatusBadge'
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 { postCampaignJson } from '@/lib/campaignJsonRequest'
import type { SpeechCutStatus } from '@/lib/speechCut'
type SpeechCutPublicationPanelProps = {
cutId: number
status: SpeechCutStatus
}
const UNPUBLISH_WARNING =
'O link público deixa de funcionar para quem já recebeu. Você pode publicar de novo quando quiser.'
/**
* C168 — the kill switch on the detail page. Publishing restores the same
* public link; unpublishing asks for confirmation with the product copy and
* takes it off the air. A cut without a stored file cannot be published.
*/
export const SpeechCutPublicationPanel = ({ cutId, status }: SpeechCutPublicationPanelProps) => {
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const setPublished = async (published: boolean) => {
setSubmitting(true)
setError(null)
try {
const { ok, payload } = await postCampaignJson<SpeechCutPublicationResponse>(
`/campanha/comunicacao/acervo/cortes/${cutId}/publicacao`,
{ cutId, published },
)
if (!ok || payload.status !== 'success') {
setError(
payload.status === 'error' ? payload.message : 'Não foi possível atualizar a publicação.',
)
return
}
router.refresh()
} catch {
setError('Não foi possível atualizar a publicação.')
} finally {
setSubmitting(false)
}
}
return (
<section className="rounded-xl border p-4">
<h2 className="text-xs tracking-wide text-muted-foreground uppercase">Publicação</h2>
<div className="mt-2">
<SpeechCutStatusBadge status={status} />
</div>
{status === 'published' ? (
<>
<p className="mt-2 text-xs text-muted-foreground">{UNPUBLISH_WARNING}</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
className="mt-3 min-h-11 w-full"
disabled={submitting}
>
Despublicar
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Despublicar este corte?</AlertDialogTitle>
<AlertDialogDescription>{UNPUBLISH_WARNING}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel type="button" className="min-h-11" disabled={submitting}>
Cancelar
</AlertDialogCancel>
<Button
type="button"
className="min-h-11"
disabled={submitting}
onClick={() => void setPublished(false)}
>
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Despublicar
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
) : null}
{status === 'unpublished' ? (
<>
<p className="mt-2 text-xs text-muted-foreground">
Despublicado, o link público não responde. Publique para reativar o mesmo link.
</p>
<Button
type="button"
className="mt-3 min-h-11 w-full"
disabled={submitting}
onClick={() => void setPublished(true)}
>
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Publicar corte
</Button>
</>
) : null}
{status === 'processing' ? (
<p className="mt-2 text-xs text-muted-foreground">
O corte ainda está sendo preparado. Assim que o arquivo ficar pronto, publique por aqui.
</p>
) : null}
{status === 'failed' ? (
<p className="mt-2 text-xs text-muted-foreground">
O corte falhou e não tem arquivo publicado. Refaça o corte no acervo de falas.
</p>
) : null}
{error ? (
<Alert variant="destructive" className="mt-3 py-2">
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
</section>
)
}

View file

@ -0,0 +1,28 @@
import { Badge } from '@/components/ui/Badge'
import { speechCutStatusLabels, type SpeechCutStatus } from '@/lib/speechCut'
type StatusBadgeVariant = 'default' | 'destructive' | 'outline'
const variantFor = (status: SpeechCutStatus): StatusBadgeVariant => {
switch (status) {
case 'published':
return 'default'
case 'failed':
return 'destructive'
default:
return 'outline'
}
}
/** C168 — the one status chip of the cut library (list and detail). */
export const SpeechCutStatusBadge = ({
status,
className = 'font-normal',
}: {
status: SpeechCutStatus
className?: string
}) => (
<Badge variant={variantFor(status)} className={className}>
{speechCutStatusLabels[status]}
</Badge>
)

View file

@ -0,0 +1,152 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import type { SpeechCutTextUpdateResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/types'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { SPEECH_CUT_DESCRIPTION_MAX_LENGTH, SPEECH_CUT_TITLE_MAX_LENGTH } from '@/lib/speechCut'
type SpeechCutTextEditorProps = {
cutId: number
initialTitle: string
initialDescription: string
/** A cut still processing/without a file has nothing to publish — and no text to trust. */
disabled?: boolean
}
/**
* C168 — edits the cut's own title/description through the JSON route. Saving
* refreshes the server tree, so the public page and the link preview reflect
* the new text (the `afterChange` revalidation is the C167 one).
*/
export const SpeechCutTextEditor = ({
cutId,
initialTitle,
initialDescription,
disabled = false,
}: SpeechCutTextEditorProps) => {
const router = useRouter()
const [title, setTitle] = useState(initialTitle)
const [description, setDescription] = useState(initialDescription)
const [saved, setSaved] = useState({ title: initialTitle, description: initialDescription })
const [submitting, setSubmitting] = useState(false)
const [feedback, setFeedback] = useState<'idle' | 'saved' | 'error'>('idle')
const [error, setError] = useState<string | null>(null)
const trimmedTitle = title.trim()
const trimmedDescription = description.trim()
const dirty =
trimmedTitle !== saved.title.trim() || trimmedDescription !== saved.description.trim()
const canSave =
!disabled && dirty && trimmedTitle.length > 0 && trimmedDescription.length > 0 && !submitting
const save = async () => {
setSubmitting(true)
setFeedback('idle')
setError(null)
try {
const { ok, payload } = await postCampaignJson<SpeechCutTextUpdateResponse>(
`/campanha/comunicacao/acervo/cortes/${cutId}/texto`,
{ cutId, title: trimmedTitle, description: trimmedDescription },
)
if (!ok || payload.status !== 'success') {
setFeedback('error')
setError(
payload.status === 'error' ? payload.message : 'Não foi possível salvar as alterações.',
)
return
}
setSaved({ title: trimmedTitle, description: trimmedDescription })
setFeedback('saved')
router.refresh()
} catch {
setFeedback('error')
setError('Não foi possível salvar as alterações.')
} finally {
setSubmitting(false)
}
}
return (
<section className="rounded-xl border p-4">
<h2 className="text-sm font-medium">Editar título e descrição</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
O que você salvar aqui aparece na página pública e no preview do link.
</p>
<div className="mt-4 flex flex-col gap-3">
<Field>
<FieldLabel htmlFor="speech-cut-title">Título</FieldLabel>
<Input
id="speech-cut-title"
value={title}
maxLength={SPEECH_CUT_TITLE_MAX_LENGTH}
disabled={disabled || submitting}
onChange={(event) => {
setTitle(event.target.value)
setFeedback('idle')
}}
/>
</Field>
<Field>
<FieldLabel htmlFor="speech-cut-description">Descrição</FieldLabel>
<Textarea
id="speech-cut-description"
value={description}
maxLength={SPEECH_CUT_DESCRIPTION_MAX_LENGTH}
rows={4}
className="min-h-24 resize-y"
disabled={disabled || submitting}
onChange={(event) => {
setDescription(event.target.value)
setFeedback('idle')
}}
/>
</Field>
</div>
{feedback === 'error' && error ? (
<Alert variant="destructive" className="mt-3 py-2">
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
<div className="mt-3 flex flex-col-reverse items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end">
{feedback === 'saved' ? (
<p className="text-xs text-muted-foreground sm:mr-auto" aria-live="polite">
Alterações salvas.
</p>
) : null}
<Button
type="button"
variant="outline"
className="min-h-11"
disabled={disabled || submitting || !dirty}
onClick={() => {
setTitle(saved.title)
setDescription(saved.description)
setFeedback('idle')
setError(null)
}}
>
Descartar
</Button>
<Button type="button" className="min-h-11" disabled={!canSave} onClick={() => void save()}>
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar alterações
</Button>
</div>
</section>
)
}

View file

@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react'
import type { Metadata } from 'next'
import {
CAMPAIGN_COMMUNICATION_CORTES,
CAMPAIGN_CONTACTS_HOME,
CAMPAIGN_HOME,
CAMPAIGN_UPDATES_HREF,
@ -120,6 +121,10 @@ export const campaignPageChromeCatalog = {
title: 'Acervo de falas',
subtitle: 'Discursos do Deputado Jorge Solla na Câmara.',
},
cortes: {
title: 'Cortes',
subtitle: 'O que já foi cortado — reencontre, ajuste o texto e republique.',
},
} as const satisfies Record<string, CampaignPageChrome | null>
const resolveCatalogEntry = (
@ -282,6 +287,12 @@ const pathRules: PathRule[] = [
match: (pathname) => pathname === '/campanha/comunicacao/acervo',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.acervo),
},
{
// C168 — before the generic acervo-detail regex below, which would
// otherwise swallow the library route and resolve it to `null`.
match: (pathname) => pathname === CAMPAIGN_COMMUNICATION_CORTES,
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.cortes),
},
{
match: (pathname) => /^\/campanha\/comunicacao\/acervo\/[^/]+$/.test(pathname),
resolve: () => null,

View file

@ -25,4 +25,7 @@ export const CAMPAIGN_COMMUNICATION_HOME = '/campanha/comunicacao' as const
export const CAMPAIGN_COMMUNICATION_ACERVO = '/campanha/comunicacao/acervo' as const
/** C168 — library of cuts made from the acervo (list + `/<id>` detail). */
export const CAMPAIGN_COMMUNICATION_CORTES = '/campanha/comunicacao/acervo/cortes' as const
export const CAMPAIGN_UPDATES_HREF = '/campanha/atualizacoes' as const

View file

@ -20,6 +20,10 @@ export const SPEECH_CUT_INVALID_RANGE_MESSAGE = 'Selecione um trecho de 5 a 180
export const SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE =
'Só é possível tentar novamente um corte que falhou.'
/** Publish is refused until the job stored the MP4 (processing/failed have none). */
export const SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE =
'Este corte ainda não tem um arquivo para publicar.'
/** Transport/ffmpeg/storage failure (or anything unmapped). */
export const SPEECH_CUT_GENERIC_ERROR_MESSAGE =
'Não foi possível preparar o corte. Verifique seu acesso e tente novamente.'
@ -51,3 +55,20 @@ export const speechCutSuggestionRequestSchema = z.object({
startSeconds: excerptBoundarySeconds,
endSeconds: excerptBoundarySeconds,
})
/** C168 — edit the cut's own text: never the source speech nor the stored video. */
export const speechCutTextUpdateRequestSchema = z.object({
cutId: positiveRelationshipId,
title: z.string().trim().min(1).max(SPEECH_CUT_TITLE_MAX_LENGTH),
description: z.string().trim().min(1).max(SPEECH_CUT_DESCRIPTION_MAX_LENGTH),
})
export type SpeechCutTextUpdateRequest = z.infer<typeof speechCutTextUpdateRequestSchema>
/** C168 — the kill switch: publish/unpublish the cut's public link. */
export const speechCutPublicationRequestSchema = z.object({
cutId: positiveRelationshipId,
published: z.boolean(),
})
export type SpeechCutPublicationRequest = z.infer<typeof speechCutPublicationRequestSchema>

View file

@ -60,7 +60,8 @@ export const removeSearchOnlyOmniboxChip = <T extends SearchOnlyListState>({
export const clearSearchOnlyOmnibox = <T extends SearchOnlyListState>({
cleared,
}: {
state: T
/** Optional for callers whose serializer already pins page 1. */
state?: T
cleared: T
}): SearchOnlyOmniboxAction<T> => ({
kind: 'clear',

View file

@ -4,8 +4,10 @@
* [start, end] MP4 and the public view model. No I/O and no `server-only`:
* the dialog (client) and the job (server) share this module.
*/
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { formatBahiaCivilDate } from '@/lib/campaignTime'
import { buildWhatsAppTextShareUrl } from '@/lib/phone'
import { formatSpeechSpan } from '@/lib/speechClock'
import { formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock'
import { parseYoutubeVideoId } from '@/lib/speechVod'
export const SPEECH_CUT_STATUSES = ['processing', 'published', 'unpublished', 'failed'] as const
@ -162,7 +164,14 @@ export const buildSpeechCutShare = ({
}
type CutMediaRecord = { url?: string | null; filename?: string | null }
type CutSpeechRecord = { youtubeUrl?: string | null }
type CutSpeechRecord = {
/** Populated at depth 1; absent when the relation was not expanded. */
id?: number
youtubeUrl?: string | null
type?: string | null
phase?: string | null
speechAt?: string | null
}
type CutDurationRecord = { durationSeconds?: number | null }
export type SpeechCutRecordForView = CutDurationRecord & {
@ -176,6 +185,7 @@ export type SpeechCutRecordForView = CutDurationRecord & {
media?: number | CutMediaRecord | null
speech?: number | CutSpeechRecord | null
publishedAt?: string | null
createdAt?: string | null
updatedAt?: string | null
}
@ -233,3 +243,47 @@ export const toSpeechCutViewModel = (record: SpeechCutRecordForView): SpeechCutV
publishedAt: record.publishedAt ?? null,
}
}
/** Origin of a cut in the library: the speech it was cut from, with its link. */
export type SpeechCutOriginViewModel = {
id: number
/** "Fala · Breves Comunicações · 11/08/2026" — readiness degrades, never empty. */
label: string
href: string
}
export type SpeechCutLibraryItemViewModel = SpeechCutViewModel & {
/** "Criado em dd/mm/aaaa" date part; null when the row has no timestamp. */
createdAtLabel: string | null
/** Null when the source speech was deleted (FK `SET NULL`) — degrade, don't break. */
origin: SpeechCutOriginViewModel | null
}
const formatCreatedAtLabel = (createdAt: string | null | undefined): string | null => {
if (!createdAt) return null
const instant = new Date(createdAt)
if (Number.isNaN(instant.getTime())) return null
return formatSpeechDate(formatBahiaCivilDate(instant))
}
const originOf = (speech: SpeechCutRecordForView['speech']): SpeechCutOriginViewModel | null => {
if (typeof speech !== 'object' || speech === null || typeof speech.id !== 'number') return null
const dateLabel = speech.speechAt ? formatSpeechDate(speech.speechAt) : null
const label =
[speech.type ?? 'Fala', speech.phase, dateLabel].filter(Boolean).join(' · ') || 'Fala'
return { id: speech.id, label, href: `${CAMPAIGN_COMMUNICATION_ACERVO}/${speech.id}` }
}
/**
* C168 — library view of a cut: the base view plus the origin speech (label +
* link to the acervo) and the creation date the list shows. `error`, `step`
* and `createdBy` still never pass through.
*/
export const toSpeechCutLibraryItemViewModel = (
record: SpeechCutRecordForView,
): SpeechCutLibraryItemViewModel => ({
...toSpeechCutViewModel(record),
createdAtLabel: formatCreatedAtLabel(record.createdAt),
origin: originOf(record.speech),
})

View file

@ -0,0 +1,29 @@
import 'server-only'
import type { Payload } from 'payload'
import type { SpeechCutRecordForView } from '@/lib/speechCut'
import type { CampaignUser } from '@/payload-types'
/**
* C167/C168 — the one read of one cut by id, with the origin speech expanded
* (depth 1). The C167 actions use it to inspect/return a cut and the C168
* library detail loader uses it to throw the named not-found error — same
* query, `overrideAccess: false` against the actor's access.
*/
export const findSpeechCutForActor = async (
payload: Payload,
actor: CampaignUser,
cutId: number,
): Promise<SpeechCutRecordForView | null> => {
const result = await payload.find({
collection: 'speechCut',
where: { id: { equals: cutId } },
depth: 1,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
})
return result.docs[0] ?? null
}

View file

@ -0,0 +1,93 @@
/**
* C168 — URL contract of the cut library list (`/campanha/comunicacao/acervo/cortes`):
* only free-text search (`q`) and pagination, over the shared `campaignListUrl`
* helpers. Facets of the speech acervo stay there — the cut is the unit and the
* origin is context of the item, not a filter dimension.
*/
import type { Where } from 'payload'
import { CAMPAIGN_COMMUNICATION_CORTES } from '@/lib/campaignPaths'
import {
buildListHref,
firstValue,
normalizedText,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
export const speechCutPageSize = 25
export type SpeechCutListState = {
page: number
q?: string
}
const speechCutListParamNames = ['q', 'page'] as const
const speechCutListParamNameSet = new Set<string>(speechCutListParamNames)
export const parseSpeechCutListParams = (params: RawSearchParams): SpeechCutListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const q = normalizedText(firstValue(params.q))
return {
page: rawPage ?? 1,
...(q ? { q } : {}),
}
}
/** Expects already-canonical state (from parse or a rule-preserving toggle). */
export const serializeCanonicalSpeechCutListSearchParams = (
canonicalState: SpeechCutListState,
): URLSearchParams => {
const params = new URLSearchParams()
if (canonicalState.q) params.set('q', canonicalState.q)
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
return params
}
const buildSpeechCutListSearchParams = (
state: SpeechCutListState,
page = state.page,
): URLSearchParams =>
serializeCanonicalSpeechCutListSearchParams(
parseSpeechCutListParams({
page: String(page),
q: state.q,
}),
)
export const buildSpeechCutListHref = (state: SpeechCutListState, page: number): string =>
buildListHref(state, buildSpeechCutListSearchParams, CAMPAIGN_COMMUNICATION_CORTES, page)
export const buildSpeechCutFiltersKey = (state: SpeechCutListState): string =>
buildSpeechCutListSearchParams(state).toString()
export const resolveSpeechCutListUrl = (
params: RawSearchParams,
totalPages?: number,
): {
state: SpeechCutListState
href: string
redirectHref?: string
} =>
resolveListUrl({
params,
paramNameSet: speechCutListParamNameSet,
parse: parseSpeechCutListParams,
buildSearchParams: buildSpeechCutListSearchParams,
basePath: CAMPAIGN_COMMUNICATION_CORTES,
totalPages,
})
/**
* Source-level `where`: `q` matches the cut's own title or description (ILIKE
* substring, case-insensitive — the operator `contactListUrl` verified against
* Postgres). The origin speech is context, never a search dimension.
*/
export const buildSpeechCutListWhere = (state: SpeechCutListState): Where => {
if (!state.q) return {}
return {
or: [{ title: { like: state.q } }, { description: { like: state.q } }],
}
}

View file

@ -0,0 +1,80 @@
import 'server-only'
import type { Payload } from 'payload'
import {
toSpeechCutLibraryItemViewModel,
type SpeechCutLibraryItemViewModel,
} from '@/lib/speechCut'
import type { CampaignUser } from '@/payload-types'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
import { findSpeechCutForActor } from '@/utilities/speech/speechCutData'
import {
buildSpeechCutListWhere,
parseSpeechCutListParams,
resolveSpeechCutListUrl,
speechCutPageSize,
type SpeechCutListState,
} from '@/utilities/speech/speechCutListUrl'
type SpeechCutListSearchParams = Record<string, string | string[] | undefined>
export const SpeechCutNotFoundError = createEntityNotFoundError(
'SpeechCut',
'Corte não encontrado.',
)
export type SpeechCutAcervoPageData = {
rows: SpeechCutLibraryItemViewModel[]
state: SpeechCutListState
redirectHref?: string
totalDocs: number
totalPages: number
}
/**
* C168 — the cut library list: newest first, optional `q` over title/description
* (ILIKE) and the house pagination. Depth 1 carries the origin speech; access is
* the collection's own (`canReadSpeechCut` for the acervo reader).
*/
export const loadSpeechCutAcervoPageData = async (
payload: Payload,
user: CampaignUser,
searchParams: Promise<SpeechCutListSearchParams> | SpeechCutListSearchParams,
): Promise<SpeechCutAcervoPageData> => {
const rawSearchParams = await searchParams
const state = parseSpeechCutListParams(rawSearchParams)
const result = await payload.find({
collection: 'speechCut',
depth: 1,
limit: speechCutPageSize,
page: state.page,
sort: '-createdAt',
where: buildSpeechCutListWhere(state),
user,
overrideAccess: false,
})
const resolvedUrl = resolveSpeechCutListUrl(rawSearchParams, result.totalPages)
return {
rows: result.docs.map((cut) => toSpeechCutLibraryItemViewModel(cut)),
state: resolvedUrl.state,
redirectHref: resolvedUrl.redirectHref,
totalDocs: result.totalDocs,
totalPages: result.totalPages,
}
}
/** One cut with its origin and stored media, for the library detail page. */
export const loadSpeechCutDetailPageData = async (
payload: Payload,
user: CampaignUser,
cutId: number,
): Promise<SpeechCutLibraryItemViewModel> => {
const cut = await findSpeechCutForActor(payload, user, cutId)
if (!cut) throw new SpeechCutNotFoundError()
return toSpeechCutLibraryItemViewModel(cut)
}

View file

@ -6,7 +6,7 @@ import { request as playwrightRequest } from '@playwright/test'
import { adminHeaders } from '../helpers/adminApi'
import { seedTestUser } from '../helpers/seedUser'
import type { CampaignE2EOwnership } from './fixtures/campaignE2EFixtures.js'
import { expect, rendered, test } from './fixtures/campaignHttpTest.js'
import { assertCampaignRedirect, expect, rendered, test } from './fixtures/campaignHttpTest.js'
/**
* C167 — speech cuts over real HTTP (browserless): the unlisted public page of
@ -244,3 +244,100 @@ test.describe('Acervo speech cuts (C167)', () => {
}
})
})
/**
* C168 — the internal cut library: list and detail render for a communicator,
* the text edit and the kill switch go through the JSON routes, the public page
* reflects the unpublish, and advisor/leader are denied both the page and the
* edit route.
*/
test.describe('Acervo cut library (C168)', () => {
test.beforeAll(async () => {
await seedTestUser()
})
test('lists, edits and toggles a cut over HTTP; the kill switch reaches the public page', async ({
campaign,
campaignRequest,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const speech = await createSpeech(campaign)
const title = 'Corte da biblioteca'
const cutId = await createCut(request, headers, {
speechId: speech.id,
status: 'published',
title,
})
const communicator = await campaign.fixtures.createCampaignUser('communicator')
const communicatorRequest = await campaignRequest(communicator, communicator.password)
const list = await communicatorRequest.get('/campanha/comunicacao/acervo/cortes')
expect(list.status()).toBe(200)
expect(rendered(await list.text())).toContain(title)
const detail = await communicatorRequest.get(`/campanha/comunicacao/acervo/cortes/${cutId}`)
expect(detail.status()).toBe(200)
const detailHtml = rendered(await detail.text())
expect(detailHtml).toContain(title)
expect(detailHtml).toContain(`/campanha/comunicacao/acervo/${speech.id}`)
const edit = await communicatorRequest.post(
`/campanha/comunicacao/acervo/cortes/${cutId}/texto`,
{ data: { cutId, title: 'Título revisado', description: 'Descrição revisada' } },
)
expect(edit.status()).toBe(200)
const editBody = (await edit.json()) as { status: string; cut: { title: string } }
expect(editBody.status).toBe('success')
expect(editBody.cut.title).toBe('Título revisado')
const unpublish = await communicatorRequest.post(
`/campanha/comunicacao/acervo/cortes/${cutId}/publicacao`,
{ data: { cutId, published: false } },
)
expect(unpublish.status()).toBe(200)
expect(((await unpublish.json()) as { cut: { status: string } }).cut.status).toBe('unpublished')
const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
try {
expect((await anonymous.get(`/corte/${cutId}`)).status()).toBe(404)
} finally {
await anonymous.dispose()
}
})
test('denies advisor/leader on the library page and on the edit route', async ({
campaign,
campaignRequest,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const speech = await createSpeech(campaign)
const cutId = await createCut(request, headers, {
speechId: speech.id,
status: 'published',
title: 'Corte restrito',
})
for (const role of ['advisor', 'leader'] as const) {
const denied = await campaign.fixtures.createCampaignUser(role)
const deniedRequest = await campaignRequest(denied, denied.password)
await assertCampaignRedirect(
deniedRequest,
'/campanha/comunicacao/acervo/cortes',
role === 'leader' ? '/campanha/meus-contatos' : '/campanha',
)
const response = await deniedRequest.post(
`/campanha/comunicacao/acervo/cortes/${cutId}/publicacao`,
{ data: { cutId, published: false } },
)
expect(response.status()).toBe(400)
expect(((await response.json()) as { message: string }).message).toContain(
'não tem acesso ao acervo',
)
}
})
})

View file

@ -30,9 +30,14 @@ vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
import {
getSpeechCutStatusForActor,
saveSpeechCutForActor,
setSpeechCutPublishedForActor,
suggestSpeechCutMetadataForActor,
updateSpeechCutTextForActor,
} from '@/app/(campaign)/campanha/actions/speech'
import { SPEECH_CUT_FORBIDDEN_MESSAGE } from '@/lib/schemas/speechCut'
import {
SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE,
} from '@/lib/schemas/speechCut'
import type { CampaignUser } from '@/payload-types'
import config from '@/payload.config'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
@ -41,6 +46,11 @@ import {
runSpeechCutJob,
SPEECH_CUT_STALE_MS,
} from '@/utilities/speech/speechCutJob'
import {
loadSpeechCutAcervoPageData,
loadSpeechCutDetailPageData,
SpeechCutNotFoundError,
} from '@/utilities/speech/speechCutPageData'
import { startSpeechCutJobInBackground } from '@/utilities/speech/speechCutScheduler'
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
@ -116,6 +126,8 @@ const createCut = async (data: {
endSeconds: number
status?: 'processing' | 'published' | 'unpublished' | 'failed'
createdBy?: number
title?: string
description?: string
}): Promise<number> => {
const cut = await payload.create({
collection: 'speechCut',
@ -124,8 +136,8 @@ const createCut = async (data: {
startSeconds: data.startSeconds,
endSeconds: data.endSeconds,
durationSeconds: data.endSeconds - data.startSeconds,
title: `Corte ${randomUUID().slice(0, 8)}`,
description: 'Descrição do corte',
title: data.title ?? `Corte ${randomUUID().slice(0, 8)}`,
description: data.description ?? 'Descrição do corte',
status: data.status ?? 'processing',
...(data.createdBy ? { createdBy: data.createdBy } : {}),
},
@ -496,3 +508,186 @@ describe('speech cuts (C167)', () => {
})
})
})
describe('speech cut library (C168)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterEach(() => {
mockedGetContext.mockReset()
})
it('lists newest first and searches title or description case-insensitively', async () => {
const speech = await createSpeech()
const marker = `zebra${randomUUID().slice(0, 8)}`
const older = await createCut({
speech,
startSeconds: 0,
endSeconds: 20,
status: 'published',
title: `Obras paradas ${marker}`,
})
const newer = await createCut({
speech,
startSeconds: 30,
endSeconds: 50,
status: 'unpublished',
title: 'Título sem o termo',
description: `Conteúdo sobre OBRAS ${marker} e emprego`,
})
const unrelated = await createCut({
speech,
startSeconds: 60,
endSeconds: 80,
status: 'published',
title: `Saúde ${marker}`,
})
const { communicator } = await createUsers()
const data = await loadSpeechCutAcervoPageData(payload, communicator, { q: `obras ${marker}` })
expect(data.rows.map((row) => row.id)).toEqual([newer, older])
expect(data.rows.map((row) => row.id)).not.toContain(unrelated)
expect(data.totalDocs).toBe(2)
})
it('carries the origin speech and degrades when it was deleted', async () => {
const speech = await createSpeech()
const withOrigin = await createCut({ speech, startSeconds: 0, endSeconds: 20 })
const orphan = await payload.create({
collection: 'speechCut',
data: {
startSeconds: 0,
endSeconds: 10,
durationSeconds: 10,
title: `Órfão ${randomUUID().slice(0, 8)}`,
description: 'Sem fala de origem',
status: 'unpublished',
},
depth: 0,
overrideAccess: true,
})
createdCutIds.add(orphan.id)
const { communicator } = await createUsers()
const withSpeech = await loadSpeechCutDetailPageData(payload, communicator, withOrigin)
expect(withSpeech.origin?.id).toBe(speech)
expect(withSpeech.origin?.href).toBe(`/campanha/comunicacao/acervo/${speech}`)
expect(withSpeech.origin?.label).toContain('07/02/2023')
expect(withSpeech.createdAtLabel).toMatch(/^\d{2}\/\d{2}\/\d{4}$/)
const withoutSpeech = await loadSpeechCutDetailPageData(payload, communicator, orphan.id)
expect(withoutSpeech.origin).toBeNull()
})
it('edits only the cut text and reflects it in the library', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 20 })
const { communicator } = await createUsers()
asActor(communicator)
const updated = await updateSpeechCutTextForActor({
cutId,
title: 'Título revisado',
description: 'Descrição revisada',
})
expect(updated.title).toBe('Título revisado')
const stored = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 0,
overrideAccess: true,
})
expect(stored.title).toBe('Título revisado')
expect(stored.description).toBe('Descrição revisada')
// The speech window and the (absent) video are untouched.
expect(stored.startSeconds).toBe(0)
expect(stored.endSeconds).toBe(20)
expect(stored.media ?? null).toBeNull()
const listed = await loadSpeechCutAcervoPageData(payload, communicator, { q: 'revisado' })
expect(listed.rows.map((row) => row.id)).toContain(cutId)
})
it('denies editing and publishing to advisor/leader fail-closed', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 20 })
const { advisor, leader } = await createUsers()
for (const denied of [advisor, leader]) {
asActor(denied)
await expect(
updateSpeechCutTextForActor({ cutId, title: 't', description: 'd' }),
).rejects.toThrow(SPEECH_CUT_FORBIDDEN_MESSAGE)
await expect(setSpeechCutPublishedForActor({ cutId, published: false })).rejects.toThrow(
SPEECH_CUT_FORBIDDEN_MESSAGE,
)
}
})
it('refuses to publish a cut without a stored file', async () => {
const speech = await createSpeech()
const cutId = await createCut({
speech,
startSeconds: 0,
endSeconds: 20,
status: 'unpublished',
})
const { communicator } = await createUsers()
asActor(communicator)
await expect(setSpeechCutPublishedForActor({ cutId, published: true })).rejects.toThrow(
SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE,
)
})
it('kills and restores the same public link through the toggle', async () => {
const speech = await createSpeech()
const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 })
const { communicator } = await createUsers()
asActor(communicator)
vi.stubGlobal('fetch', camaraFetchStub(MP4_BYTES))
const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG)
try {
await runSpeechCutJob(payload, cutId)
const publishedCut = await payload.findByID({
collection: 'speechCut',
id: cutId,
depth: 1,
overrideAccess: true,
})
const media =
typeof publishedCut.media === 'object' && publishedCut.media !== null
? publishedCut.media
: null
if (media) createdMediaIds.add(media.id)
expect(publishedCut.status).toBe('published')
expect((await findCutsAs(undefined, [cutId])).docs.map((doc) => doc.id)).toEqual([cutId])
const unpublished = await setSpeechCutPublishedForActor({ cutId, published: false })
expect(unpublished.status).toBe('unpublished')
expect(unpublished.publicPath).toBe(`/corte/${cutId}`)
expect((await findCutsAs(undefined, [cutId])).docs).toHaveLength(0)
const republished = await setSpeechCutPublishedForActor({ cutId, published: true })
expect(republished.status).toBe('published')
expect(republished.publicPath).toBe(`/corte/${cutId}`)
expect((await findCutsAs(undefined, [cutId])).docs.map((doc) => doc.id)).toEqual([cutId])
} finally {
restoreFfmpeg()
vi.unstubAllGlobals()
}
})
it('throws the named not-found error for a missing cut', async () => {
const { communicator } = await createUsers()
await expect(
loadSpeechCutDetailPageData(payload, communicator, 999_999_999),
).rejects.toBeInstanceOf(SpeechCutNotFoundError)
})
})

View file

@ -0,0 +1,85 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
buildSpeechCutFiltersKey,
buildSpeechCutListHref,
buildSpeechCutListWhere,
parseSpeechCutListParams,
resolveSpeechCutListUrl,
serializeCanonicalSpeechCutListSearchParams,
} from '@/utilities/speech/speechCutListUrl'
describe('parseSpeechCutListParams', () => {
it('keeps the trimmed query and the page', () => {
expect(parseSpeechCutListParams({ q: ' Saúde ', page: '3' })).toEqual({
page: 3,
q: 'Saúde',
})
})
it('drops an empty query and an invalid page', () => {
expect(parseSpeechCutListParams({ q: ' ', page: '0' })).toEqual({ page: 1 })
expect(parseSpeechCutListParams({})).toEqual({ page: 1 })
})
})
describe('serializeCanonicalSpeechCutListSearchParams', () => {
it('omits the default page and serializes a real one', () => {
expect(serializeCanonicalSpeechCutListSearchParams({ page: 1 }).toString()).toBe('')
expect(serializeCanonicalSpeechCutListSearchParams({ page: 2 }).toString()).toBe('page=2')
})
it('canonicalizes through parse', () => {
expect(
serializeCanonicalSpeechCutListSearchParams(
parseSpeechCutListParams({ q: 'obras', page: '1' }),
).toString(),
).toBe('q=obras')
})
})
describe('buildSpeechCutListHref', () => {
it('points at the library and clamps the page into the query', () => {
expect(buildSpeechCutListHref(parseSpeechCutListParams({ q: 'SUS' }), 2)).toBe(
'/campanha/comunicacao/acervo/cortes?q=SUS&page=2',
)
expect(buildSpeechCutListHref(parseSpeechCutListParams({}), 1)).toBe(
'/campanha/comunicacao/acervo/cortes',
)
})
})
describe('resolveSpeechCutListUrl', () => {
it('canonicalizes unsupported params into a redirect', () => {
const resolved = resolveSpeechCutListUrl({ q: 'saude', desconhecido: 'x', page: '2' })
expect(resolved.state.q).toBe('saude')
expect(resolved.redirectHref).toBe('/campanha/comunicacao/acervo/cortes?q=saude&page=2')
})
it('clamps a page beyond the total', () => {
const resolved = resolveSpeechCutListUrl({ q: 'saude', page: '9' }, 2)
expect(resolved.state.page).toBe(2)
expect(resolved.redirectHref).toContain('page=2')
})
})
describe('buildSpeechCutFiltersKey', () => {
it('is empty without filters and stable with them', () => {
expect(buildSpeechCutFiltersKey(parseSpeechCutListParams({}))).toBe('')
expect(buildSpeechCutFiltersKey(parseSpeechCutListParams({ q: 'obras' }))).toBe('q=obras')
})
})
describe('buildSpeechCutListWhere', () => {
it('is empty without a query', () => {
expect(buildSpeechCutListWhere({ page: 1 })).toEqual({})
})
it('matches title or description', () => {
expect(buildSpeechCutListWhere({ page: 1, q: 'SUS' })).toEqual({
or: [{ title: { like: 'SUS' } }, { description: { like: 'SUS' } }],
})
})
})