mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:25:18 +00:00
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:
parent
352801853f
commit
2d1fd5f158
33 changed files with 1921 additions and 50 deletions
1
docs/changelog/2026-09-15-c168.md
Normal file
1
docs/changelog/2026-09-15-c168.md
Normal 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).
|
||||
186
docs/plans/c168-biblioteca-cortes-impl.md
Normal file
186
docs/plans/c168-biblioteca-cortes-impl.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
47
src/components/CopyLinkButton.tsx
Normal file
47
src/components/CopyLinkButton.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
55
src/components/campaign/speech/SpeechCutLibraryCard.tsx
Normal file
55
src/components/campaign/speech/SpeechCutLibraryCard.tsx
Normal 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>
|
||||
)
|
||||
88
src/components/campaign/speech/SpeechCutLibraryFilters.tsx
Normal file
88
src/components/campaign/speech/SpeechCutLibraryFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
src/components/campaign/speech/SpeechCutLibraryList.tsx
Normal file
14
src/components/campaign/speech/SpeechCutLibraryList.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
33
src/components/campaign/speech/SpeechCutOriginCard.tsx
Normal file
33
src/components/campaign/speech/SpeechCutOriginCard.tsx
Normal 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>
|
||||
)
|
||||
30
src/components/campaign/speech/SpeechCutPlayer.tsx
Normal file
30
src/components/campaign/speech/SpeechCutPlayer.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
149
src/components/campaign/speech/SpeechCutPublicationPanel.tsx
Normal file
149
src/components/campaign/speech/SpeechCutPublicationPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/components/campaign/speech/SpeechCutStatusBadge.tsx
Normal file
28
src/components/campaign/speech/SpeechCutStatusBadge.tsx
Normal 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>
|
||||
)
|
||||
152
src/components/campaign/speech/SpeechCutTextEditor.tsx
Normal file
152
src/components/campaign/speech/SpeechCutTextEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
29
src/utilities/speech/speechCutData.ts
Normal file
29
src/utilities/speech/speechCutData.ts
Normal 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
|
||||
}
|
||||
93
src/utilities/speech/speechCutListUrl.ts
Normal file
93
src/utilities/speech/speechCutListUrl.ts
Normal 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 } }],
|
||||
}
|
||||
}
|
||||
80
src/utilities/speech/speechCutPageData.ts
Normal file
80
src/utilities/speech/speechCutPageData.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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',
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
85
tests/unit/speechCutListUrl.unit.spec.ts
Normal file
85
tests/unit/speechCutListUrl.unit.spec.ts
Normal 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' } }],
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue