From 9de601b7aa31cd44952525669ecbc310a5672924 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 16 Sep 2026 20:09:06 -0300 Subject: [PATCH] feat(C183): apaga um corte da biblioteca e recupera cortes falhados (Closes #1105) --- docs/changelog/2026-09-16-c183.md | 1 + .../biblioteca-cortes-apagar-e-falhas-impl.md | 169 +++++ ...teca-cortes-apagar-e-falhas-ui-design.html | 609 +++++++++++++++++- .../acervo/cortes/[id]/apagar/route.ts | 60 ++ .../comunicacao/acervo/cortes/[id]/page.tsx | 70 +- .../acervo/cortes/[id]/retry/route.ts | 33 + .../comunicacao/acervo/cortes/[id]/types.ts | 10 + src/app/(campaign)/campanha/actions/speech.ts | 46 ++ src/collections/SpeechCut.ts | 4 +- .../campaign/speech/SpeechCutDeleteDialog.tsx | 132 ++++ .../campaign/speech/SpeechCutLibraryCard.tsx | 18 +- .../speech/SpeechCutLibraryCardActions.tsx | 42 ++ .../speech/SpeechCutPublicationPanel.tsx | 2 +- .../campaign/speech/SpeechCutRetryButton.tsx | 76 +++ .../campaign/speech/SpeechCutTextEditor.tsx | 15 +- src/lib/campaignJsonRequest.ts | 29 +- src/lib/campaignPaths.ts | 7 + src/lib/schemas/speechCut.ts | 7 + src/utilities/access/speeches.ts | 13 + src/utilities/campaignAccess.ts | 7 +- src/utilities/campaignJsonMutationRoute.ts | 10 +- tests/e2e/campaignSpeechCut.e2e.spec.ts | 63 ++ tests/int/speechCut.int.spec.ts | 120 ++++ tests/unit/codebaseConventions.unit.spec.ts | 16 + tests/unit/speechCutCardActions.unit.spec.tsx | 89 +++ .../unit/speechCutDeleteDialog.unit.spec.tsx | 103 +++ 26 files changed, 1696 insertions(+), 55 deletions(-) create mode 100644 docs/changelog/2026-09-16-c183.md create mode 100644 docs/plans/biblioteca-cortes-apagar-e-falhas-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/apagar/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/retry/route.ts create mode 100644 src/components/campaign/speech/SpeechCutDeleteDialog.tsx create mode 100644 src/components/campaign/speech/SpeechCutLibraryCardActions.tsx create mode 100644 src/components/campaign/speech/SpeechCutRetryButton.tsx create mode 100644 tests/unit/speechCutCardActions.unit.spec.tsx create mode 100644 tests/unit/speechCutDeleteDialog.unit.spec.tsx diff --git a/docs/changelog/2026-09-16-c183.md b/docs/changelog/2026-09-16-c183.md new file mode 100644 index 00000000..dc3ed9f2 --- /dev/null +++ b/docs/changelog/2026-09-16-c183.md @@ -0,0 +1 @@ +**C183 — biblioteca de cortes: apagar um corte e recuperar falhados (2026-09-16):** a assessoria de comunicação passa a limpar a própria biblioteca de cortes e a recuperar uma falha onde ela aparece, sem voltar à fala de origem. Dois becos do C168: só admin do Payload apagava, e um corte `failed` ficava como item morto (o "Tentar novamente" do C169 morava só no diálogo da fala). Entra o predicado nomeado `canDeleteSpeechCut` (admin do Payload **ou** `communicator`/`coordinator`/`candidate`; `advisor`/`leader` fail-closed) em `src/utilities/access/speeches.ts`, ligado ao `SpeechCut.access.delete` (antes `payloadAdminOnly`) — nunca um alias de `canReadSpeech`, para que alargar a leitura não conceda escrita em silêncio. O delete é hard, com confirmação explícita: o diálogo avisa o link público `/corte/` só quando o corte está `published` ("O link público … deixa de funcionar para quem já recebeu. Esta ação não pode ser desfeita."); nos demais, só "Esta ação não pode ser desfeita.". Transporte em `DELETE /campanha/comunicacao/acervo/cortes/[id]/apagar` — sub-segmento obrigatório, já que `page.tsx` e `route.ts` não coexistem no mesmo segmento; como `campaignJsonMutationRoute` constrói só POST, o handler repete `isSameOriginRequest` (403) e reusa o envelope exportado `campaignJsonMutationErrorResponse`, e o sweep de `codebaseConventions` ganha a regra "todo DELETE sob `(campaign)` chama `isSameOriginRequest`". O retry reusa o dono C167: `retrySpeechCutForActor` é casca fina sobre a `retrySpeechCut` privada (mesma row, recusa não-`failed`), exposta em `POST /campanha/comunicacao/acervo/cortes/[id]/retry`; o card `failed` e o cabeçalho do detalhe ganham "Tentar novamente" (primeira ação, full-width no mobile) e "Apagar". O detalhe de um corte não-pronto troca o editor desabilitado pelo bloco atenuado "Texto do corte" (Cena E) e o card/lista usam a ilha cliente `SpeechCutLibraryCardActions`. Sem migration (access é runtime), sem soft delete/lote/métricas; o `media` órfão do delete fica adiado com gatilho de revisitação no impl plan. Design tier: primary. Testes: unit (`speechCutDeleteDialog`, `speechCutCardActions`, sweep do `codebaseConventions`), int (`deleteSpeechCutForActor`, negação advisor/leader, admin preservado, retry na mesma row, órfão de media documentado) e e2e (DELETE derruba o `/corte/` e retry de um `failed`). Issue #1105. diff --git a/docs/plans/biblioteca-cortes-apagar-e-falhas-impl.md b/docs/plans/biblioteca-cortes-apagar-e-falhas-impl.md new file mode 100644 index 00000000..b82f31df --- /dev/null +++ b/docs/plans/biblioteca-cortes-apagar-e-falhas-impl.md @@ -0,0 +1,169 @@ +# Impl: Biblioteca de cortes: apagar um corte e não acumular cortes falhados + +Status: aprovado +Atualizado em: 2026-09-16 +Issue: #1105 +Intenção: docs/plans/biblioteca-cortes-apagar-e-falhas.md +Appetite restante: herdado (~1 dia eng) — sem corte. O item cabe: uma autorização de delete, uma action de delete, uma action de retry que **reusa** o dono C167, duas ilhas de UI e o endurecimento do guard de rotas. + +## Leitura da intenção + +- **Outcome:** em `/campanha/comunicacao/acervo/cortes` (lista) e `/campanha/comunicacao/acervo/cortes/[id]` (detalhe), `communicator`/`coordinator`/`candidate` apagam um corte com confirmação explícita — avisando o link público `/corte/` quando `published` — e um corte `failed` nunca fica como item morto: aparece com "Tentar novamente" acionável. `advisor`/`leader` negados (fail-closed); o admin do Payload segue podendo apagar. +- **O que NÃO negociar:** hard delete apenas (sem soft delete/lixeira/desfazer); sem lote; sem editor de vídeo; sem métricas nem leitura nova de coleções; gate `speechCatalog`, `canReadSpeechCut` (published-only para o leitor não-gate) e `leader` lockdown intocados; `/corte/` continua dono C167 (aqui só o efeito de apagar); retry é por request (`retryOf`) na **mesma row**, nunca duplica; `create`/`update` seguem `canReadSpeech` e o delete nasce com a mesma audiência. +- **O que reavaliar (hipóteses do explorador que a engenharia corrige):** + - O hint "`[id]/route.ts` (DELETE)" **não é viável**: `[id]/` já tem `page.tsx`, e o App Router proíbe `page.tsx` + `route.ts` no mesmo segmento ("Conflicting route and page"). O handler DELETE vai para um sub-segmento irmão de `publicacao`/`texto`: `[id]/apagar/route.ts`. + - O artefato de design cobre lista (A), confirmações (B/C) e mobile (D), mas **não tem cena do detalhe**; o designer estende em paralelo. O plano lista a superfície do detalhe e trata o artefato como fonte visual de verdade, reusando os mesmos componentes/copy. + - "Extrair retry reusável" tem duas leituras (reusar o endpoint `cortar` com `{retryOf}` vs extrair `retrySpeechCutForActor` + rota `[id]/retry`). A escolha está em Decisões de engenharia. + +## Abordagem recomendada + +```mermaid +flowchart LR + G[acervo gate: communicator / coordinator / candidate] --> L[Lista /cortes] + G --> D[Detalhe /cortes/id] + L -->|Apagar - publicado, despublicado ou falhou| DELR[DELETE /cortes/id/apagar] + D -->|Apagar| DELR + L -->|Tentar novamente - failed| RET[POST /cortes/id/retry] + D -->|Tentar novamente - failed| RET + DELR --> OG{isSameOriginRequest + gate + canDeleteSpeechCut} + OG -->|denied| FORB[400 fail-closed] + OG -->|ok| DEL[payload.delete overrideAccess:false] + DEL -->|afterDelete revalidateTag| PUB[/corte/id responde 404] + RET --> RG{gate speechCatalog + status failed} + RG -->|nao failed| ERR[400: so corte que falhou] + RG -->|ok| RJ[retrySpeechCut: MESMA row -> processing + job] + RJ --> REFRESH[card e detalhe voltam a processar] +``` + +**Opções consideradas:** A (novo `canDeleteSpeechCut` + `DELETE [id]/apagar` + extrair `retrySpeechCutForActor` + manter `failed` listado) | B (reusar `canReadSpeech` e o endpoint `cortar` com `{retryOf}`, sem rota nova) | C (bypass `overrideAccess:true` na action, delete só-admin na collection) +**Recomendação:** A — porque cada superfície tem um dono nomeado (access no collection, transport na família `[id]/*`, retry no mesmo módulo C167 via extração, UI nas ilhas existentes) e porque só A protege o efeito público (`afterDelete` já revalida; o link vira 404) sem afrouxar o `where` de leitura. +**Rejeitadas:** B — `canReadSpeech` como predicado de delete faz a semântica de leitura virar autoridade de escrita (um futuro alargamento de leitura concederia delete em silêncio) e expor `{retryOf}` como contrato da biblioteca acopla o card à união create/retry do diálogo; C — `overrideAccess:true` sem predicado dono é o caminho paralelo que o brief proíbe e some com a auditoria. + +### Decisões de engenharia + +**1. Autorização do delete** + +- **Opções:** A) novo `canDeleteSpeechCut` (admin Payload OU `canReadSpeechCatalog`) | B) reusar `canReadSpeech` | C) manter `payloadAdminOnly` e fazer o delete da campanha com `overrideAccess:true` após gate no action. +- **Recomendação:** **A** — `src/utilities/access/speeches.ts` ganha `canDeleteSpeechCut` com o mesmo predicado de audiência (`isPayloadAdmin || canReadSpeechCatalog(currentUser.role)`), re-exportado pelo barrel `campaignAccess.ts` e ligado em `SpeechCut.access.delete`. O action ainda repete o gate fresco (`canReadSpeechCatalog`) para responder a mensagem de domínio; o access do collection é a segunda camada (defense in depth) e é ele que sustenta o `/api` do Payload. +- **Rejeitadas:** **B** porque reaproveitar um predicado de leitura como autoridade de escrita é uma decisão silenciosa de segurança (o nome não conta a verdade; `canDeleteX` é o padrão do repo — `canDeleteOwnNotifications`, `canDeleteCalendarFeed`); **C** porque um `overrideAccess:true` sem dono no collection precisa de comentário de bypass e vira o "gêmeo" que o engineering-brief manda evitar. + +**2. Transport do delete (e o conflito de rota)** + +- **Opções:** A) `DELETE` em `[id]/apagar/route.ts`, irmão de `publicacao`/`texto`, com `isSameOriginRequest` manual | B) `POST` via `campaignJsonMutationRoute` em `[id]/apagar` com body `{cutId}` | C) server action chamada direto do client. +- **Recomendação:** **A**. O detalhe já usa route handlers por sub-segmento e o client já fala JSON com `postCampaignJson`; DELETE sem corpo usa o `id` do segmento. Como `campaignJsonMutationRoute` constrói **só POST** e o sweep de convenções só guarda `export const POST`, este handler: (a) chama `isSameOriginRequest(request)` e devolve 403; (b) valida `strictDecimalInteger(id)`; (c) reusa o **mesmo** mapeador/envelope de erro exportado de `campaignJsonMutationRoute.ts` (editar o dono, não re-escrever o envelope); (d) `dynamic = 'force-dynamic'`. O sweep é **estendido para `export const DELETE`** em `codebaseConventions.unit.spec.ts` (escopo `src/app/(campaign)`, exigindo `isSameOriginRequest(`), porque o guard que falha aberto é exatamente a linha que a sexta rota esquece. +- **Rejeitadas:** **B** porque POST para apagar destoa do verbo e o item não precisa de corpo (o id já está no path); **C** porque o padrão do detalhe é JSON por route handler (publicação/texto), e chamada direta de server action sairia do guard de origem compartilhado. +- **Nota de rota:** `[id]/route.ts` **não existe e não pode existir** ao lado de `[id]/page.tsx`; o sub-segmento `apagar/` é obrigatório, não cosmético. + +**3. Corte `failed`: manter listado com retry no card** + +- **Opções:** A) manter `failed` na lista (`buildSpeechCutListWhere` segue `{}`) e renderizar "Tentar novamente" no card e no detalhe | B) dropar `failed` do `where` da lista. +- **Recomendação:** **A** — o design aprovado decide retry no nível do card (Cena A: card `Falhou` com "Tentar novamente" + "Abrir corte" + "Apagar"). `buildSpeechCutListWhere` e `parseSpeechCutListParams`/serialize/`paramNameSet` **não mudam** (sem faceta nova, sem mudança de URL; o unit `speechCutListUrl.unit.spec.ts:75-85` segue verde). +- **Rejeitadas:** **B** porque contradiz o design aprovado e remove o caminho de recuperação de onde a pessoa vê a falha (o C169 existe justamente para falha honesta + retry por row). + +**4. Reuso do retry (não twinar o C167)** + +- **Opções:** A) extrair a action exportada `retrySpeechCutForActor({cutId})` (delegando à `retrySpeechCut` privada já existente) + rota `POST [id]/retry/route.ts` via `campaignJsonMutationRoute` | B) o card chama o endpoint existente `/campanha/comunicacao/acervo/cortar` com `{retryOf: cutId}` | C) segunda action/rota com lógica própria. +- **Recomendação:** **A** — o que se extrai é uma casca fina: parse (`speechCutRetryRequestSchema`), gate fresco `canReadSpeechCatalog`, e delegação à `retrySpeechCut(payload, actor, cutId)` **existente** (que já exige `status === 'failed'`, reusa a row e reinicia o job). `saveSpeechCutForActor` continua chamando a privada no ramo `retryOf` (sem duplo parse). O que **fica**: a `retrySpeechCut` privada e o diálogo intocados. +- **Rejeitadas:** **B** porque `retryOf` é o contrato privado de seleção do diálogo e vira acoplamento do card à união create/retry; **C** é o twin proibido. + +**5. Órfão de `media` no delete** + +- **Opções:** A) adiar (não apagar `media`/objeto S3 no delete) com gatilho | B) apagar `media` na mesma transação do delete do corte. +- **Recomendação:** **A** — `payload.delete` do corte **não** cascateia para `media` (relação separada; o MP4 e o objeto no bucket ficam). O retry já deixa órfão do mesmo jeito (`retrySpeechCut` zera `media: null` sem apagar o anterior), então o delete **não introduz classe nova** de resíduo. Adiar mantém o appetite e evita escrita multi-collection + delete S3 + risco de apagar arquivo compartilhado. +- **Rejeitadas:** **B** porque exige transação sobre `media` + `speechCut`, tratamento de falha do S3 e decisão de propriedade do arquivo — caro de reverter sem sinal de produto. +- **Gatilho de revisitação:** auditoria de órfãos ou sinal de custo/crescimento do bucket → item próprio de limpeza corte→media (e retry→media). + +**6. Apagar enquanto `processing`** + +- **Opções:** A) não renderizar "Apagar" enquanto `status === 'processing'` | B) permitir em todos os status. +- **Recomendação:** **B** — a Cena E do artefato aprovado é explícita: "Publicado, despublicado e processando: 'Apagar' no mesmo extremo direito". O dono da estrutura visual decidiu; o delete não é escondido por estado. O job em background que ainda segura a row apenas falha e loga se a row sumir (o `after` do Next contém a rejeição) — resíduo possível é um `media` órfão, já coberto pela Decisão 5. +- **Rejeitadas:** **A** porque contraria o design aprovado (trigger b) e esconderia a saída definitiva justamente de quem quer cancelar um processamento travado. +- **Gatilho de revisitação:** se o job passar a ter efeito colateral caro (upload/registro externo), reavaliar com cancelamento explícito do job. + +### Componentes / mudanças + +**Servidor / acesso** + +- **`canDeleteSpeechCut`** (`src/utilities/access/speeches.ts`): novo predicado (admin Payload OU `canReadSpeechCatalog`), o dono da autorização. Reusa `isPayloadAdmin`/`getFreshCampaignUser` de `access/shared`. +- **Barrel** (`src/utilities/campaignAccess.ts:172`): re-exporta `canDeleteSpeechCut`. +- **`SpeechCut`** (`src/collections/SpeechCut.ts:51-56`): `access.delete = canDeleteSpeechCut`; remove o import de `payloadAdminOnly` (deixa de ser usado no arquivo — evita lint/knip). +- **Schemas** (`src/lib/schemas/speechCut.ts`): `speechCutCutIdRequestSchema` (`{ cutId: positiveRelationshipId }`), tipo inferido — **um** schema reusado pelo DELETE e pelo retry (o `speechCutStatusRequestSchema` existente tem a mesma forma mas nome de poll; não duplicar). Reusa `SPEECH_CUT_FORBIDDEN_MESSAGE`/`SPEECH_CUT_NOT_FOUND_MESSAGE`/`SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE`/`SPEECH_CUT_GENERIC_ERROR_MESSAGE` (sem literal novo). +- **Actions** (`src/app/(campaign)/campanha/actions/speech.ts`): `deleteSpeechCutForActor({cutId})` (parse → gate fresco → `loadSpeechCutForActor` → `payload.delete({ collection:'speechCut', id, user: actor, overrideAccess:false })`; **single-collection, sem transação**) e `retrySpeechCutForActor({cutId})` (parse → gate fresco → delega a `retrySpeechCut`). Devolvem `{ deleted: true }` / `SpeechCutViewModel`. +- **Envelope de erro** (`src/utilities/campaignJsonMutationRoute.ts`): exportar o mapeador de erro já existente (`campaignJsonMutationErrorResponse`) para o handler DELETE reusar 401/400 e `safeMessages`, sem re-escrever o envelope. + +**Rotas** + +- **`DELETE`** (`src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/apagar/route.ts`): `isSameOriginRequest` → 403; `strictDecimalInteger(id)`; chama `deleteSpeechCutForActor`; responde `SpeechCutDeleteResponse`; `dynamic = 'force-dynamic'`. +- **`POST`** (`.../cortes/[id]/retry/route.ts`): `campaignJsonMutationRoute({ bodySchema: speechCutRetryRequestSchema, safeMessages: [FORBIDDEN, NOT_FOUND, RETRY_NOT_FAILED], genericMessage })`. +- **Tipos** (`.../cortes/[id]/types.ts`): `SpeechCutDeleteResponse = { status:'success'; deleted: true } | { status:'error'; message }` e `SpeechCutRetryResponse = { status:'success'; cut: SpeechCutViewModel } | { status:'error'; message }`. + +**Client / contrato** + +- **`deleteCampaignJson`** (`src/lib/campaignJsonRequest.ts`): variante DELETE do preâmbulo JSON (`credentials:'same-origin'`, `Accept`), ao lado de `postCampaignJson` — edita o dono do transporte, não cria módulo. +- **`campaignPaths.ts`**: `campaignSpeechCutDeleteHref(id)` e `campaignSpeechCutRetryHref(id)` (endpoints internos) para o card e o detalhe não repetirem string. + +**UI (Impeccable B — shells existentes; shape→craft→critique→polish)** + +- **`SpeechCutDeleteDialog`** (`src/components/campaign/speech/SpeechCutDeleteDialog.tsx`, client): `AlertDialog` de `@/components/ui/AlertDialog` (precedente C168), copy literal do artefato. `published`: título "Apagar este corte?", corpo "O link público /corte/ deixa de funcionar para quem já recebeu. Esta ação não pode ser desfeita."; demais: mesmo título, corpo "Esta ação não pode ser desfeita."; botões "Cancelar" / "Apagar" (destructive, com `Spinner`). Props: `{ cutId, publicPath, status, onDeleted?, redirectAfterDelete? }` — na lista `onDeleted` faz `router.refresh()`; no detalhe redireciona para `CAMPAIGN_COMMUNICATION_CORTES` (a row some). +- **`SpeechCutRetryButton`** (novo, client): mostra "Tentar novamente" só quando `failed`; POST em `campaignSpeechCutRetryHref(id)` via `postCampaignJson`; `Spinner` + erro; `router.refresh()`. +- **`SpeechCutLibraryCardActions`** (novo, client island): "Abrir corte" (`campaignSpeechCutDetailHref`), "Copiar link" (só `published`, `CopyLinkButton`), "Tentar novamente" (`failed`), "Apagar" à direita (destructive, em todos os status — Cena E). Grid mobile do design (Cena D). +- **`SpeechCutLibraryCard.tsx`** (server): passa a renderizar a ilha de ações (mantém o card RSC). +- **Detalhe** (`.../cortes/[id]/page.tsx`): ilha de ações no topo (Apagar + Tentar novamente quando `failed`), usando os mesmos componentes. Superfície não desenhada no artefato atual — a cena do detalhe é extensão em paralelo; sem estilo novo, mesma copy/componentes. +- **`SpeechCutPublicationPanel.tsx`** (`:136-140`): troca o beco "Refaça o corte no acervo de falas" por copy que aponta para a ação "Tentar novamente" (a ação vive na ilha do detalhe; o painel não chama o job). + +**Testes** + +- **Unit**: `tests/unit/codebaseConventions.unit.spec.ts` (sweep estendido a `export const DELETE` sob `(campaign)`, exigindo `isSameOriginRequest(`); novo `tests/unit/speechCutDeleteDialog.unit.spec.tsx` (copy publicado vs não publicado, Cancelar/Apagar, DELETE na URL certa, erro); `speechCutCardActions.unit.spec.tsx` (`failed` → "Tentar novamente" no endpoint de retry; todos os status com Apagar). `speechCutListUrl.unit.spec.ts` **sem mudança** (pina `where === {}`). +- **Int**: `tests/int/speechCut.int.spec.ts` (describe C168) — `deleteSpeechCutForActor` remove a row e ela some de `loadSpeechCutAcervoPageData`; `advisor`/`leader` negados (`SPEECH_CUT_FORBIDDEN_MESSAGE`); delete de `published` **não** apaga `media` (documenta o órfão); `retrySpeechCutForActor` só em `failed`, mesma row → `processing`, job disparado, `published` rejeitado; delete via collection access com `overrideAccess:false` (communicator ok, advisor/leader negados). +- **E2E**: `tests/e2e/campaignSpeechCut.e2e.spec.ts` (describe C168) — communicator `DELETE /campanha/comunicacao/acervo/cortes//apagar` → 200, some da lista e `/corte/` → 404; `advisor`/`leader` → 400 `não tem acesso ao acervo`; `POST ...//retry` em corte `failed` → 200 `processing`. + +### Dados → forma (se aplicável) + +Não aplicável — a intenção já decide que é estado do item, não painel (sem contadores/métricas). Nenhuma leitura agregada nova; a lista continua card-based com o sistema de listas existente (`SpeechCutLibraryFilters`/`CampaignListPendingBoundary` intocados). + +## Fases verificáveis + +1. **Tracer / server+access (≈meio dia):** `canDeleteSpeechCut` + barrel + `SpeechCut.access.delete` + schemas + `deleteSpeechCutForActor`/`retrySpeechCutForActor` + rotas `apagar`/`retry` + export do mapeador de erro + sweep DELETE do `codebaseConventions` + int/unit de autorização, remoção e retry. Prova: `pnpm test` (unit+int) verde; `payload.delete` com `overrideAccess:false` negando advisor/leader. +2. **UI (≈meio dia):** `deleteCampaignJson`, helpers de path, `SpeechCutDeleteDialog`, `SpeechCutRetryButton`, `SpeechCutCardActions`, edição do card e do detalhe, ajuste da copy `failed` do painel; unit dos componentes e extensão do e2e. Prova: card `failed` com retry, confirmação com aviso quando `published`, `/corte/` vira 404 após apagar. +3. **Gates:** `pnpm gate:fast` (lint → format → typecheck → knip → cycles → unit → int); e2e no conjunto curado do CI; push via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- **Limpeza do órfão de `media`/S3** (delete e retry) — adiada com gatilho (Decisão 5); sem `payload.delete` de `media` e sem transação multi-collection aqui. +- **Soft delete / lixeira / desfazer / restauração** — anti-goal de produto. +- **Apagar ou tentar em lote** — anti-goal de produto. +- **Apagar `processing`** — adiado com gatilho (Decisão 6); sem cancelamento de job. +- **Faceta/filtro de status na URL** — desnecessário (failed segue listado); não tocar `parseSpeechCutListParams`/serialize/`paramNameSet`/`buildSpeechCutListWhere`. +- **Wrapper genérico `campaignDeleteRoute`** — 1 call site; não abstrair. A segurança vem do `isSameOriginRequest` explícito + sweep estendido. +- **Retry na seção "Cortes desta fala"** (C174) — fora; a action extraída torna barato depois, se pedido. +- **Métricas/telemetria de exclusão**, comentários, coleções/kits — fora. +- **Mudar `/corte/` (C167)** além do efeito do delete, o gate `speechCatalog` e o `where` published-only de `canReadSpeechCut` — intocados. + +### Explicitamente fora (triage pós-`/simplify`) + +- **`deleteCampaignJson` repete o preâmbulo do `postCampaignJson`** (score 2, cheap_polish) — **defer, gatilho:** fatorar um `sendCampaignJson(url, init)` no mesmo módulo quando surgir o **2º** chamador bodyless/DELETE (hoje 1 call site; DRY prematuro). +- **Slug `[id]/retry` em inglês ao lado de `publicacao`/`texto`/`apagar`** (score 2, cheap_polish) — **defer, gatilho:** renomear para um slug pt-BR se a superfície de endpoints do corte for tocada de novo ou ganhar um irmão. +- **Tipo de opções do envelope re-declarado inline** (`campaignJsonMutationRoute`) — descartado (polish de tipo, sem custo de comportamento). +- **`[id]` do retry não lido no path** (o `cutId` vem do body) e **pré-leitura antes do delete** — descartados: convenção pré-existente da família (`publicacao`/`texto`) e produtor deliberado da mensagem de domínio, respectivamente. +- **`canDeleteSpeechCut` textualmente igual a `canReadSpeech`** e **`speechCutCutIdRequestSchema` igual em forma ao `speechCutStatusRequestSchema`** — mantidos de propósito (Decisões 1 e 3): o nome do predicado é a fronteira de escrita e o schema de status tem semântica de poll. + +## Riscos e mitigação + +- **Conflito `page.tsx` + `route.ts`** em `[id]/` → sub-segmento `[id]/apagar/route.ts` obrigatório; coberto pelo build (falha explícita) e citado no plano. +- **DELETE sem guard de origem falha aberto** → `isSameOriginRequest` manual + sweep estendido para `export const DELETE` (escopo `(campanha)`, allowlist do REST do Payload) para uma rota futura não repetir o erro. +- **Audiência do delete divergir** → predicado nomeado + gate fresco no action + collection access; int pinando advisor/leader negados e admin preservado. +- **Link público**: apagar `published` quebra `/corte/` já distribuído → confirmação com aviso verbatim; `afterDelete` (`SpeechCut.ts:64-68`) revalida o tag `document_speechCut:`, então a página passa a 404 (não serve cache velho). +- **Corrida com o job em `processing`** → delete desabilitado nesse estado (Decisão 6) com gatilho de revisitação. +- **Retry duplicando corte** → `retrySpeechCutForActor` delega à `retrySpeechCut` existente (reusa a row; recusa não-`failed`); diálogo C167 intocado. +- **Regressão no `where`/URL da lista** → nenhuma mudança em `speechCutListUrl.ts`; unit existente pina `{}`. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: apagar com confirmação (aviso do link quando `published`); `failed` nunca morto (card + detalhe com "Tentar novamente"); `communicator`/`coordinator`/`candidate` + admin do Payload podem; `advisor`/`leader` fail-closed; hard delete sem undo/lote/métricas. +- [ ] Invariantes AGENTS/engineering-standards: Local API com `user` em `overrideAccess:false`; delete é single-collection (sem transação) e o órfão de media está nomeado como não-escopo com gatilho; sem `Consent`/PII novo; gate `speechCatalog`/`leader` lockdown e `/corte/` (C167) intocados; copy pt-BR / identificadores em inglês. +- [ ] Testes de domínio previstos onde access/write paths mudam: int (delete/retry/autorização/remoção/órfão), unit (diálogo/card/sweep DELETE) e e2e (HTTP delete+retry+negação). +- [ ] **Migration:** **sem migration** — nenhum campo/schema muda (`access.delete` é config de runtime, não coluna). Confirmado: `SpeechCut` não ganha campo; nada de `pnpm migrate:create`. + +## Self-score (decision-quality) + +5/5 — (1) as decisões caras (autorização, transport, retry, órfão, processamento) têm opções e rejeitadas explícitas; (2) cabe no appetite herdado (~1 dia; uma action de delete, uma de retry que delega ao dono, duas ilhas de UI); (3) rabbit holes nomeados (media/S3, lote, soft delete, `processing`, faceta de status, wrapper genérico); (4) depth check respeitado — reusa `AlertDialog`, `copyLink`, `withPayloadTransaction`/padrão de erros, `campaignJsonMutationRoute`, `loadSpeechCutForActor` e a `retrySpeechCut` existente, sem twinar o C167; (5) o outcome de produto permanece idêntico — a engenharia só escolheu o como, corrigindo a rota inviável sem mudar o aceite. diff --git a/docs/plans/biblioteca-cortes-apagar-e-falhas-ui-design.html b/docs/plans/biblioteca-cortes-apagar-e-falhas-ui-design.html index ea588dfa..a25fdf11 100644 --- a/docs/plans/biblioteca-cortes-apagar-e-falhas-ui-design.html +++ b/docs/plans/biblioteca-cortes-apagar-e-falhas-ui-design.html @@ -92,8 +92,31 @@

+ + -
+

Cena A · desktop ~1280

@@ -385,9 +408,11 @@
-
+
-

Cenas B e C · confirmação

+

+ Cenas B e C · confirmação na lista e no detalhe +

O aviso cresce somente quando existe link público

@@ -512,7 +537,7 @@
-
+

Cena D · mobile ~390

@@ -685,6 +710,582 @@

+ + +
+
+
+

Cena E · detalhe desktop ~1280

+

+ A falha oferece recuperação no cabeçalho, sem esconder a saída definitiva +

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

Comunicação

+

Título do corte que não foi concluído

+
+
+
+ +
+ + + Voltar aos cortes + + +
+
+
+ Falhou + Corte de 00:35 · criado em 15 set. 2026 +
+

Título do corte que não foi concluído

+
+
+ + +
+
+ +
+
+
+ O arquivo do corte ainda não está disponível. +
+
+

Texto do corte

+

+ Título e descrição ficam disponíveis para edição quando o processamento + terminar. +

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

Regra do cabeçalho em todos os estados

+

+ Publicado, despublicado e processando: “Apagar” no mesmo extremo direito. Falhou: + “Tentar novamente” primário imediatamente antes de “Apagar”. +

+
+
+

Retry · sem confirmação

+
+ + Desabilita as duas ações até concluir. +
+
+
+
+ + +
+
+

Cena F · detalhe publicado ~1280

+

+ Apagar no detalhe reutiliza exatamente a confirmação do link público +

+
+
+
+
+
+

Comunicação

+

Título do corte publicado

+
+
+
+
+ + + Voltar aos cortes + +
+
+
+ PublicadoCorte de 00:42 · criado em 16 set. 2026 +
+

Título do corte publicado

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

+ No detalhe despublicado, processando ou falho, o mesmo gatilho abre a Cena C: somente + “Esta ação não pode ser desfeita.”, com “Cancelar” e “Apagar”. +

+
+ + +
+
+

Cena G · detalhe mobile ~390

+

+ A hierarquia se mantém com alvos de toque de 44 px +

+
+
+
+

Publicado

+
+
+ + Título do corte publicado +
+
+
+ Voltar aos cortes +
+
+ Publicado00:42 · 16 set. 2026 +
+

Título do corte publicado

+ +
+
+
+ +
+
+

+ Discurso de origem +

+

Fala de origem

+ Ver fala no acervo +
+
+ +
+
+ +
+

Falhou

+
+
+ + Corte não concluído +
+
+
+ Voltar aos cortes +
+
+ Falhou00:35 · 15 set. 2026 +
+

Título do corte que não foi concluído

+
+ + +
+
+
+ O arquivo do corte ainda não está disponível. +
+
+

Publicação

+
+ Falhou +
+

+ Não foi possível preparar o arquivo. Tente novamente sem refazer o corte. +

+
+
+ +
+
+
+
+ Ao tocar “Apagar”, o diálogo ocupa a largura disponível com + mx-4 e reutiliza as Cenas B/C. No mobile, + os botões ficam empilhados: “Apagar” acima e “Cancelar” abaixo na leitura visual, + preservando a ordem DOM segura do diálogo. +
+
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/apagar/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/apagar/route.ts new file mode 100644 index 00000000..2596bf4e --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/apagar/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server' + +import { deleteSpeechCutForActor } from '@/app/(campaign)/campanha/actions/speech' +import { + SPEECH_CUT_FORBIDDEN_MESSAGE, + SPEECH_CUT_GENERIC_ERROR_MESSAGE, + SPEECH_CUT_NOT_FOUND_MESSAGE, +} from '@/lib/schemas/speechCut' +import { + CAMPAIGN_JSON_INVALID_REQUEST_MESSAGE, + campaignJsonMutationErrorResponse, +} from '@/utilities/campaignJsonMutationRoute' +import { strictDecimalInteger } from '@/utilities/campaignListUrl' +import { isSameOriginRequest } from '@/utilities/sameOriginRequest' + +import type { SpeechCutDeleteResponse } from '../types' + +type RouteContext = { + params: Promise<{ id: string }> +} + +export const dynamic = 'force-dynamic' + +/** + * C183 — hard deletes one cut. It cannot ride `campaignJsonMutationRoute` + * (that wrapper builds POST and parses a JSON body; the id is in the path), so + * it repeats the same-origin guard and error envelope explicitly. The + * `codebaseConventions` sweep pins the `isSameOriginRequest` call for every + * DELETE route. + */ +export const DELETE = async ( + request: Request, + context: RouteContext, +): Promise> => { + if (!isSameOriginRequest(request)) { + return NextResponse.json( + { status: 'error', message: CAMPAIGN_JSON_INVALID_REQUEST_MESSAGE }, + { status: 403 }, + ) + } + + const { id } = await context.params + const cutId = strictDecimalInteger(id) + if (!cutId) { + return NextResponse.json( + { status: 'error', message: SPEECH_CUT_NOT_FOUND_MESSAGE }, + { status: 400 }, + ) + } + + try { + await deleteSpeechCutForActor({ cutId }) + return NextResponse.json({ status: 'success', deleted: true }) + } catch (error) { + return campaignJsonMutationErrorResponse(error, { + safeMessages: [SPEECH_CUT_FORBIDDEN_MESSAGE, SPEECH_CUT_NOT_FOUND_MESSAGE], + genericMessage: SPEECH_CUT_GENERIC_ERROR_MESSAGE, + }) + } +} diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx index 404bfeb7..92a986ee 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx @@ -6,10 +6,13 @@ import { getPayload } from 'payload' import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { SpeechCutDeleteDialog } from '@/components/campaign/speech/SpeechCutDeleteDialog' 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 { SpeechCutRetryButton } from '@/components/campaign/speech/SpeechCutRetryButton' +import { SpeechCutStatusBadge } from '@/components/campaign/speech/SpeechCutStatusBadge' import { SpeechCutTextEditor } from '@/components/campaign/speech/SpeechCutTextEditor' import { Button } from '@/components/ui/button' import { campaignPageMetadata } from '@/lib/campaignPageChrome' @@ -43,7 +46,11 @@ export async function generateMetadata({ params }: SpeechCutDetailPageProps) { } } -/** C168 — one cut: player, origin speech, editable text and the kill switch. */ +/** + * C168 — one cut: player, origin speech, editable text and the kill switch. + * C183 — the header carries the recovery of a failed cut ("Tentar novamente") + * and the delete, the same actions the library card exposes. + */ export default async function SpeechCutDetailPage({ params }: SpeechCutDetailPageProps) { const [{ id }, user, payload] = await Promise.all([ params, @@ -72,29 +79,60 @@ export default async function SpeechCutDetailPage({ params }: SpeechCutDetailPag +
+
+
+ + + Corte de {cut.durationLabel} + {cut.createdAtLabel ? <> · criado em {cut.createdAtLabel} : null} + +
+

{cut.title}

+
+
+ {cut.status === 'failed' ? ( + + ) : null} + +
+
+
-

{cut.title}

-

- Corte de {cut.durationLabel} - {cut.createdAtLabel ? <> · criado em {cut.createdAtLabel} : null} -

- -
- -
+
- + {notReady ? ( + + ) : ( + + )}
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/retry/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/retry/route.ts new file mode 100644 index 00000000..a8a7700d --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/retry/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server' + +import { retrySpeechCutForActor } from '@/app/(campaign)/campanha/actions/speech' +import { + SPEECH_CUT_FORBIDDEN_MESSAGE, + SPEECH_CUT_GENERIC_ERROR_MESSAGE, + SPEECH_CUT_NOT_FOUND_MESSAGE, + SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE, + speechCutCutIdRequestSchema, +} from '@/lib/schemas/speechCut' +import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' + +import type { SpeechCutRetryResponse } from '../types' + +export const dynamic = 'force-dynamic' + +/** C183 — retries a failed cut from the library card/detail (same row, C167 job). */ +export const POST = campaignJsonMutationRoute( + { + bodySchema: speechCutCutIdRequestSchema, + safeMessages: [ + SPEECH_CUT_FORBIDDEN_MESSAGE, + SPEECH_CUT_NOT_FOUND_MESSAGE, + SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE, + ], + genericMessage: SPEECH_CUT_GENERIC_ERROR_MESSAGE, + }, + async (body) => { + const cut = await retrySpeechCutForActor(body) + + return NextResponse.json({ status: 'success', cut }) + }, +) diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/types.ts b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/types.ts index 928b9f4a..8dad48f9 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/types.ts +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/types.ts @@ -9,3 +9,13 @@ export type SpeechCutTextUpdateResponse = export type SpeechCutPublicationResponse = | { status: 'success'; cut: SpeechCutViewModel } | { status: 'error'; message: string } + +/** Wire contract of `DELETE .../cortes/[id]/apagar` (C183) — hard delete. */ +export type SpeechCutDeleteResponse = + | { status: 'success'; deleted: true } + | { status: 'error'; message: string } + +/** Wire contract of `POST .../cortes/[id]/retry` (C183) — retry a failed cut. */ +export type SpeechCutRetryResponse = + | { status: 'success'; cut: SpeechCutViewModel } + | { status: 'error'; message: string } diff --git a/src/app/(campaign)/campanha/actions/speech.ts b/src/app/(campaign)/campanha/actions/speech.ts index f5d0c800..80547234 100644 --- a/src/app/(campaign)/campanha/actions/speech.ts +++ b/src/app/(campaign)/campanha/actions/speech.ts @@ -10,11 +10,13 @@ import { SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE, SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE, SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE, + speechCutCutIdRequestSchema, speechCutPublicationRequestSchema, speechCutRequestSchema, speechCutStatusRequestSchema, speechCutSuggestionRequestSchema, speechCutTextUpdateRequestSchema, + type SpeechCutCutIdRequest, type SpeechCutPublicationRequest, type SpeechCutRequest, type SpeechCutTextUpdateRequest, @@ -317,6 +319,50 @@ export const setSpeechCutPublishedForActor = async ( return toSpeechCutViewModel(cut) } +/** + * C183 — deletes one cut (hard delete): the assessoria cleans the library and + * the public `/corte/` (when it existed) stops responding. The collection's + * `delete` access (`canDeleteSpeechCut`) is the authority; this action repeats + * the fresh gate so a denied actor gets the domain message. Single-collection, + * so no transaction. The stored `media` row is deliberately left behind (the + * retry already orphans it the same way) — see the impl plan's revisit trigger. + */ +export const deleteSpeechCutForActor = async ( + input: SpeechCutCutIdRequest, +): Promise<{ deleted: true }> => { + const parsed = speechCutCutIdRequestSchema.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) + + await payload.delete({ + collection: 'speechCut', + id: parsed.cutId, + user: actor, + overrideAccess: false, + }) + return { deleted: true } +} + +/** + * C183 — retries a failed cut from where the person sees the failure (the + * library card or the detail header), without going back to the origin speech. + * A thin shell over the C167 `retrySpeechCut`: same row, same job, no new flow. + */ +export const retrySpeechCutForActor = async ( + input: SpeechCutCutIdRequest, +): Promise => { + const parsed = speechCutCutIdRequestSchema.parse(input) + const { payload, actor } = await getCampaignActionContext() + + if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE) + + return retrySpeechCut(payload, actor, parsed.cutId) +} + /** AI suggestion for the picked window; falls back deterministically, never blocks. */ export const suggestSpeechCutMetadataForActor = async (input: { speechId: number diff --git a/src/collections/SpeechCut.ts b/src/collections/SpeechCut.ts index 23bd9bc4..d1e36127 100644 --- a/src/collections/SpeechCut.ts +++ b/src/collections/SpeechCut.ts @@ -9,10 +9,10 @@ import { speechCutStepLabels, } from '@/lib/speechCut' import { + canDeleteSpeechCut, canReadSpeech, canReadSpeechCut, canSetCampaignSystemField, - payloadAdminOnly, } from '@/utilities/campaignAccess' import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields' import { revalidateDocumentById } from '@/utilities/documents' @@ -52,7 +52,7 @@ export const SpeechCut: CollectionConfig = { create: canReadSpeech, read: canReadSpeechCut, update: canReadSpeech, - delete: payloadAdminOnly, + delete: canDeleteSpeechCut, }, hooks: { beforeChange: [stampCampaignCreatedBy], diff --git a/src/components/campaign/speech/SpeechCutDeleteDialog.tsx b/src/components/campaign/speech/SpeechCutDeleteDialog.tsx new file mode 100644 index 00000000..4bef7659 --- /dev/null +++ b/src/components/campaign/speech/SpeechCutDeleteDialog.tsx @@ -0,0 +1,132 @@ +'use client' + +import { CircleAlertIcon } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useState } from 'react' + +import type { SpeechCutDeleteResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/types' +import { Alert, AlertDescription } from '@/components/ui/Alert' +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/AlertDialog' +import { Button } from '@/components/ui/button' +import { Spinner } from '@/components/ui/Spinner' +import { deleteCampaignJson } from '@/lib/campaignJsonRequest' +import { campaignSpeechCutDeleteHref } from '@/lib/campaignPaths' +import type { SpeechCutStatus } from '@/lib/speechCut' +import { cn } from '@/lib/utils' + +type SpeechCutDeleteDialogProps = { + cutId: number + status: SpeechCutStatus + publicPath: string + /** Layout of the trigger (list card right-aligns; detail/mobile goes full width). */ + triggerClassName?: string + /** Detail page: the row is gone after the delete, so leave the page. */ + redirectTo?: string +} + +const DELETE_ERROR_MESSAGE = 'Não foi possível apagar o corte.' + +/** + * C183 — the one delete confirmation of the cut surfaces. The warning grows only + * when there is a public link: a `published` cut names the `/corte/` that + * stops working for whoever already received it. + */ +export const SpeechCutDeleteDialog = ({ + cutId, + status, + publicPath, + triggerClassName, + redirectTo, +}: SpeechCutDeleteDialogProps) => { + const router = useRouter() + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const confirmDelete = async () => { + setSubmitting(true) + setError(null) + + try { + const { ok, payload } = await deleteCampaignJson( + campaignSpeechCutDeleteHref(cutId), + ) + + if (!ok || payload.status !== 'success') { + setError(payload.status === 'error' ? payload.message : DELETE_ERROR_MESSAGE) + return + } + + if (redirectTo) router.push(redirectTo) + else router.refresh() + } catch { + setError(DELETE_ERROR_MESSAGE) + } finally { + setSubmitting(false) + } + } + + return ( + + + + + + +
+
+
+
+ Apagar este corte? + + {status === 'published' ? ( + <> + O link público {publicPath}{' '} + deixa de funcionar para quem já recebeu. Esta ação não pode ser desfeita. + + ) : ( + 'Esta ação não pode ser desfeita.' + )} + +
+
+
+ + + Cancelar + + + + {error ? ( + + {error} + + ) : null} +
+
+ ) +} diff --git a/src/components/campaign/speech/SpeechCutLibraryCard.tsx b/src/components/campaign/speech/SpeechCutLibraryCard.tsx index 850090a1..a87d7570 100644 --- a/src/components/campaign/speech/SpeechCutLibraryCard.tsx +++ b/src/components/campaign/speech/SpeechCutLibraryCard.tsx @@ -1,17 +1,13 @@ -import { PlayIcon } from 'lucide-react' import Link from 'next/link' -import { CopyLinkButton } from '@/components/CopyLinkButton' +import { SpeechCutLibraryCardActions } from '@/components/campaign/speech/SpeechCutLibraryCardActions' import { SpeechCutStatusBadge } from '@/components/campaign/speech/SpeechCutStatusBadge' -import { Button } from '@/components/ui/button' -import { campaignSpeechCutDetailHref } 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. + * the actions (open, copy when live, retry a failure, delete) as a client island. */ export const SpeechCutLibraryCard = ({ cut }: { cut: SpeechCutLibraryItemViewModel }) => (
{cut.durationLabel}
-
- - {cut.status === 'published' ? : null} -
+ ) diff --git a/src/components/campaign/speech/SpeechCutLibraryCardActions.tsx b/src/components/campaign/speech/SpeechCutLibraryCardActions.tsx new file mode 100644 index 00000000..03477874 --- /dev/null +++ b/src/components/campaign/speech/SpeechCutLibraryCardActions.tsx @@ -0,0 +1,42 @@ +'use client' + +import { PlayIcon } from 'lucide-react' +import Link from 'next/link' + +import { SpeechCutDeleteDialog } from '@/components/campaign/speech/SpeechCutDeleteDialog' +import { SpeechCutRetryButton } from '@/components/campaign/speech/SpeechCutRetryButton' +import { CopyLinkButton } from '@/components/CopyLinkButton' +import { Button } from '@/components/ui/button' +import { campaignSpeechCutDetailHref } from '@/lib/campaignPaths' +import type { SpeechCutLibraryItemViewModel, SpeechCutStatus } from '@/lib/speechCut' + +/** + * C183 — the library card's action row: the recovery of a failed cut comes + * first and full width on mobile (Cena D), then open/copy, with delete + * isolated at the right (desktop) or full width (mobile published). + */ +const deleteTriggerClassName = (status: SpeechCutStatus): string => + status === 'published' ? 'w-full sm:ml-auto sm:w-auto' : 'flex-1 sm:ml-auto sm:flex-none' + +export const SpeechCutLibraryCardActions = ({ cut }: { cut: SpeechCutLibraryItemViewModel }) => ( +
+ {cut.status === 'failed' ? ( + + ) : null} + + {cut.status === 'published' ? ( + + ) : null} + +
+) diff --git a/src/components/campaign/speech/SpeechCutPublicationPanel.tsx b/src/components/campaign/speech/SpeechCutPublicationPanel.tsx index 659736ee..f42ecce4 100644 --- a/src/components/campaign/speech/SpeechCutPublicationPanel.tsx +++ b/src/components/campaign/speech/SpeechCutPublicationPanel.tsx @@ -135,7 +135,7 @@ export const SpeechCutPublicationPanel = ({ cutId, status }: SpeechCutPublicatio {status === 'failed' ? (

- O corte falhou e não tem arquivo publicado. Refaça o corte no acervo de falas. + Não foi possível preparar o arquivo. Tente novamente sem refazer o corte.

) : null} diff --git a/src/components/campaign/speech/SpeechCutRetryButton.tsx b/src/components/campaign/speech/SpeechCutRetryButton.tsx new file mode 100644 index 00000000..09d94946 --- /dev/null +++ b/src/components/campaign/speech/SpeechCutRetryButton.tsx @@ -0,0 +1,76 @@ +'use client' + +import { RotateCcwIcon } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useState } from 'react' + +import type { SpeechCutRetryResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/types' +import { Button } from '@/components/ui/button' +import { Spinner } from '@/components/ui/Spinner' +import { postCampaignJson } from '@/lib/campaignJsonRequest' +import { campaignSpeechCutRetryHref } from '@/lib/campaignPaths' +import { cn } from '@/lib/utils' + +type SpeechCutRetryButtonProps = { + cutId: number + className?: string +} + +const RETRY_ERROR_MESSAGE = 'Não foi possível tentar novamente.' + +/** + * C183 — retries a failed cut in place (the library card and the detail header), + * so the recovery lives where the person sees the failure. Reuses the C167 + * request (`retryOf` → same row), never reshapes the cut. + */ +export const SpeechCutRetryButton = ({ cutId, className }: SpeechCutRetryButtonProps) => { + const router = useRouter() + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const retry = async () => { + setSubmitting(true) + setError(null) + + try { + const { ok, payload } = await postCampaignJson( + campaignSpeechCutRetryHref(cutId), + { cutId }, + ) + + if (!ok || payload.status !== 'success') { + setError(payload.status === 'error' ? payload.message : RETRY_ERROR_MESSAGE) + return + } + + router.refresh() + } catch { + setError(RETRY_ERROR_MESSAGE) + } finally { + setSubmitting(false) + } + } + + return ( + <> + + {error ? ( +

+ {error} +

+ ) : null} + + ) +} diff --git a/src/components/campaign/speech/SpeechCutTextEditor.tsx b/src/components/campaign/speech/SpeechCutTextEditor.tsx index 67a8daa0..7c6ad704 100644 --- a/src/components/campaign/speech/SpeechCutTextEditor.tsx +++ b/src/components/campaign/speech/SpeechCutTextEditor.tsx @@ -17,20 +17,18 @@ 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). + * the new text (the `afterChange` revalidation is the C167 one). Only rendered + * for a cut with a stored file; a pending/failed one shows the C183 placeholder. */ export const SpeechCutTextEditor = ({ cutId, initialTitle, initialDescription, - disabled = false, }: SpeechCutTextEditorProps) => { const router = useRouter() const [title, setTitle] = useState(initialTitle) @@ -44,8 +42,7 @@ export const SpeechCutTextEditor = ({ 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 canSave = dirty && trimmedTitle.length > 0 && trimmedDescription.length > 0 && !submitting const save = async () => { setSubmitting(true) @@ -91,7 +88,7 @@ export const SpeechCutTextEditor = ({ id="speech-cut-title" value={title} maxLength={SPEECH_CUT_TITLE_MAX_LENGTH} - disabled={disabled || submitting} + disabled={submitting} onChange={(event) => { setTitle(event.target.value) setFeedback('idle') @@ -107,7 +104,7 @@ export const SpeechCutTextEditor = ({ maxLength={SPEECH_CUT_DESCRIPTION_MAX_LENGTH} rows={4} className="min-h-24 resize-y" - disabled={disabled || submitting} + disabled={submitting} onChange={(event) => { setDescription(event.target.value) setFeedback('idle') @@ -132,7 +129,7 @@ export const SpeechCutTextEditor = ({ type="button" variant="outline" className="min-h-11" - disabled={disabled || submitting || !dirty} + disabled={submitting || !dirty} onClick={() => { setTitle(saved.title) setDescription(saved.description) diff --git a/src/lib/campaignJsonRequest.ts b/src/lib/campaignJsonRequest.ts index 78df5714..07a41de3 100644 --- a/src/lib/campaignJsonRequest.ts +++ b/src/lib/campaignJsonRequest.ts @@ -1,9 +1,9 @@ /** - * Transport half of every `/campanha` JSON POST — the list cells' quick edits - * and the WebAuthn ceremonies: the preamble each one repeated around `fetch` - * (same-origin credentials, JSON headers, the cast of the discriminated - * response). Deliberately knows nothing about revert, retry or debounce — that - * is `useCampaignCellAutosave`'s, or the caller's. + * Transport half of every `/campanha` JSON mutation — the list cells' quick + * edits, the WebAuthn ceremonies and the C183 cut delete: the preamble each one + * repeated around `fetch` (same-origin credentials, JSON headers, the cast of + * the discriminated response). Deliberately knows nothing about revert, retry or + * debounce — that is `useCampaignCellAutosave`'s, or the caller's. * * `ok` travels next to the payload because a route can answer a real body with * a non-200 status (`engagement-level` returns its `blocked` state as 409), so @@ -24,3 +24,22 @@ export const postCampaignJson = async ( return { ok: response.ok, payload: (await response.json()) as TResponse } } + +/** + * DELETE half of the same `/campanha` JSON transport (C183 — remove a cut): + * no body, so the id lives in the URL; the same-origin credentials and the + * `{ ok, payload }` shape are kept identical to `postCampaignJson`. + */ +export const deleteCampaignJson = async ( + url: string, + signal?: AbortSignal, +): Promise<{ ok: boolean; payload: TResponse }> => { + const response = await fetch(url, { + method: 'DELETE', + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + signal, + }) + + return { ok: response.ok, payload: (await response.json()) as TResponse } +} diff --git a/src/lib/campaignPaths.ts b/src/lib/campaignPaths.ts index 795ebea7..748e2cbe 100644 --- a/src/lib/campaignPaths.ts +++ b/src/lib/campaignPaths.ts @@ -32,4 +32,11 @@ export const CAMPAIGN_COMMUNICATION_CORTES = '/campanha/comunicacao/acervo/corte export const campaignSpeechCutDetailHref = (id: number): string => `${CAMPAIGN_COMMUNICATION_CORTES}/${id}` +/** C183 — JSON endpoints the library card and the detail call to remove/retry a cut. */ +export const campaignSpeechCutDeleteHref = (id: number): string => + `${campaignSpeechCutDetailHref(id)}/apagar` + +export const campaignSpeechCutRetryHref = (id: number): string => + `${campaignSpeechCutDetailHref(id)}/retry` + export const CAMPAIGN_UPDATES_HREF = '/campanha/atualizacoes' as const diff --git a/src/lib/schemas/speechCut.ts b/src/lib/schemas/speechCut.ts index c842515e..e73978d3 100644 --- a/src/lib/schemas/speechCut.ts +++ b/src/lib/schemas/speechCut.ts @@ -50,6 +50,13 @@ export const speechCutStatusRequestSchema = z.object({ cutId: positiveRelationshipId, }) +/** C183 — a bare cut id: the delete body and the library retry body. */ +export const speechCutCutIdRequestSchema = z.object({ + cutId: positiveRelationshipId, +}) + +export type SpeechCutCutIdRequest = z.infer + /** AI title/description suggestion for the picked window. */ export const speechCutSuggestionRequestSchema = z.object({ speechId: positiveRelationshipId, diff --git a/src/utilities/access/speeches.ts b/src/utilities/access/speeches.ts index 4ee78f9f..803747f9 100644 --- a/src/utilities/access/speeches.ts +++ b/src/utilities/access/speeches.ts @@ -30,6 +30,19 @@ export const canUpdateSpeech: Access = async ({ req }) => { return isCampaignUnrestricted(await getFreshCampaignUser(req)) } +/** + * C183 — who may remove a cut from the library: the same acervo audience that + * reads/edits it (`communicator`, `coordinator`, `candidate`) plus the Payload + * admin. Written as its own predicate, never `canReadSpeech`, so a future + * widening of the read surface cannot silently grant delete. + */ +export const canDeleteSpeechCut: Access = async ({ req }) => { + if (isPayloadAdmin(req.user)) return true + + const currentUser = await getFreshCampaignUser(req) + return currentUser ? canReadSpeechCatalog(currentUser.role) : false +} + /** * C167 — a cut is public by link when `published`; every other status (and the * internal `error`/`step`) stays behind the acervo gate. Returning a `where` diff --git a/src/utilities/campaignAccess.ts b/src/utilities/campaignAccess.ts index f194ce4a..8d929481 100644 --- a/src/utilities/campaignAccess.ts +++ b/src/utilities/campaignAccess.ts @@ -169,4 +169,9 @@ export { canUpdateCalendarFeed, } from '@/utilities/access/calendarFeeds' -export { canReadSpeech, canReadSpeechCut, canUpdateSpeech } from '@/utilities/access/speeches' +export { + canDeleteSpeechCut, + canReadSpeech, + canReadSpeechCut, + canUpdateSpeech, +} from '@/utilities/access/speeches' diff --git a/src/utilities/campaignJsonMutationRoute.ts b/src/utilities/campaignJsonMutationRoute.ts index b3334e01..9a7b1eb3 100644 --- a/src/utilities/campaignJsonMutationRoute.ts +++ b/src/utilities/campaignJsonMutationRoute.ts @@ -11,6 +11,9 @@ import { isSameOriginRequest } from '@/utilities/sameOriginRequest' type CampaignJsonErrorBody = { status: 'error'; message: string } +/** The 403 body of a cross-origin mutation — shared with the DELETE handlers. */ +export const CAMPAIGN_JSON_INVALID_REQUEST_MESSAGE = 'Requisição inválida.' + /** * Structural on purpose: any zod schema satisfies it, and the shell stays out * of the validation library's type surface. @@ -47,8 +50,11 @@ const parseCampaignJsonRequestBody = async ( * Maps a thrown error to the JSON error response: safe messages (incl. the * two auth ones, appended once here) pass through as 400, an expired/missing * session is 401, anything else collapses to `genericMessage`. + * + * Exported for the DELETE handlers that cannot use the POST-only wrapper + * (C183's cut delete) but must keep the same envelope and status mapping. */ -const campaignJsonMutationErrorResponse = ( +export const campaignJsonMutationErrorResponse = ( error: unknown, { safeMessages, genericMessage }: { safeMessages: readonly string[]; genericMessage: string }, ): NextResponse => { @@ -97,7 +103,7 @@ export const campaignJsonMutationRoute = async (request: Request): Promise> => { if (!isSameOriginRequest(request)) { return NextResponse.json( - { status: 'error', message: 'Requisição inválida.' }, + { status: 'error', message: CAMPAIGN_JSON_INVALID_REQUEST_MESSAGE }, { status: 403 }, ) } diff --git a/tests/e2e/campaignSpeechCut.e2e.spec.ts b/tests/e2e/campaignSpeechCut.e2e.spec.ts index 73d38240..7b8ad3ba 100644 --- a/tests/e2e/campaignSpeechCut.e2e.spec.ts +++ b/tests/e2e/campaignSpeechCut.e2e.spec.ts @@ -341,8 +341,71 @@ test.describe('Acervo cut library (C168)', () => { expect(((await response.json()) as { message: string }).message).toContain( 'não tem acesso ao acervo', ) + + const deleteResponse = await deniedRequest.delete( + `/campanha/comunicacao/acervo/cortes/${cutId}/apagar`, + ) + expect(deleteResponse.status()).toBe(400) + expect(((await deleteResponse.json()) as { message: string }).message).toContain( + 'não tem acesso ao acervo', + ) } }) + + test('deletes a cut over HTTP (public link dies) and retries a failed one', async ({ + campaign, + campaignRequest, + request, + }) => { + const headers = await adminHeaders(request, BASE_URL) + const speech = await createSpeech(campaign) + const title = `Corte apagável ${randomUUID().slice(0, 8)}` + 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 publicBefore = await playwrightRequest.newContext({ baseURL: campaign.baseURL }) + try { + expect((await publicBefore.get(`/corte/${cutId}`)).status()).toBe(200) + } finally { + await publicBefore.dispose() + } + + const deleted = await communicatorRequest.delete( + `/campanha/comunicacao/acervo/cortes/${cutId}/apagar`, + ) + expect(deleted.status()).toBe(200) + expect(((await deleted.json()) as { status: string }).status).toBe('success') + + const list = await communicatorRequest.get('/campanha/comunicacao/acervo/cortes') + expect(list.status()).toBe(200) + expect(rendered(await list.text())).not.toContain(title) + + const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL }) + try { + expect((await anonymous.get(`/corte/${cutId}`)).status()).toBe(404) + } finally { + await anonymous.dispose() + } + + const failedTitle = `Corte falho ${randomUUID().slice(0, 8)}` + const failedCut = await createCut(request, headers, { + speechId: speech.id, + status: 'failed', + title: failedTitle, + }) + const retried = await communicatorRequest.post( + `/campanha/comunicacao/acervo/cortes/${failedCut}/retry`, + { data: { cutId: failedCut } }, + ) + expect(retried.status()).toBe(200) + expect(((await retried.json()) as { cut: { status: string } }).cut.status).toBe('processing') + }) }) /** diff --git a/tests/int/speechCut.int.spec.ts b/tests/int/speechCut.int.spec.ts index 75d501d5..81be18b3 100644 --- a/tests/int/speechCut.int.spec.ts +++ b/tests/int/speechCut.int.spec.ts @@ -31,7 +31,9 @@ vi.mock('@/utilities/campaignActionContext', async (importOriginal) => { }) import { + deleteSpeechCutForActor, getSpeechCutStatusForActor, + retrySpeechCutForActor, saveSpeechCutForActor, setSpeechCutPublishedForActor, suggestSpeechCutMetadataForActor, @@ -40,6 +42,7 @@ import { import { SPEECH_CUT_FORBIDDEN_MESSAGE, SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE, + SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE, } from '@/lib/schemas/speechCut' import { SPEECH_CUT_FAILURE_INTERRUPTED, SPEECH_CUT_FAILURE_UNAVAILABLE } from '@/lib/speechCut' import type { CampaignUser } from '@/payload-types' @@ -218,6 +221,17 @@ const findCutsAs = (user: CampaignUser | undefined, ids: readonly number[]) => overrideAccess: false, }) +const findCutById = async (id: number) => { + const result = await payload.find({ + collection: 'speechCut', + where: { id: { equals: id } }, + depth: 0, + limit: 1, + overrideAccess: true, + }) + return result.docs[0] ?? null +} + const asActor = (actor: CampaignUser) => mockedGetContext.mockResolvedValue({ payload, actor }) const withFfmpegPath = (value: string | undefined): (() => void) => { @@ -830,6 +844,112 @@ describe('speech cut library (C168)', () => { loadSpeechCutDetailPageData(payload, communicator, 999_999_999), ).rejects.toBeInstanceOf(SpeechCutNotFoundError) }) + + it('deletes the row and removes it from the library (C183)', async () => { + const speech = await createSpeech() + const marker = `apagavel${randomUUID().slice(0, 8)}` + const cutId = await createCut({ + speech, + startSeconds: 0, + endSeconds: 20, + title: `Corte ${marker}`, + }) + const { communicator } = await createUsers() + asActor(communicator) + + await expect(deleteSpeechCutForActor({ cutId })).resolves.toEqual({ deleted: true }) + expect(await findCutById(cutId)).toBeNull() + + const listed = await loadSpeechCutAcervoPageData(payload, communicator, { q: marker }) + expect(listed.rows.map((row) => row.id)).not.toContain(cutId) + }) + + it('denies delete to advisor/leader and keeps it for the Payload admin (C183)', async () => { + const speech = await createSpeech() + const { advisor, leader } = await createUsers() + const admin = await campaignFixtures().createAdminUser() + + for (const denied of [advisor, leader]) { + const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 20 }) + await expect( + payload.delete({ + collection: 'speechCut', + id: cutId, + user: denied, + overrideAccess: false, + }), + ).rejects.toThrow() + expect(await findCutById(cutId)).not.toBeNull() + + asActor(denied) + await expect(deleteSpeechCutForActor({ cutId })).rejects.toThrow(SPEECH_CUT_FORBIDDEN_MESSAGE) + } + + const adminCut = await createCut({ speech, startSeconds: 30, endSeconds: 50 }) + await expect( + payload.delete({ + collection: 'speechCut', + id: adminCut, + user: admin, + overrideAccess: false, + }), + ).resolves.toBeDefined() + expect(await findCutById(adminCut)).toBeNull() + }) + + it('retries a failed cut in place and refuses the others (C183)', async () => { + const speech = await createSpeech() + const failed = await createCut({ speech, startSeconds: 0, endSeconds: 20, status: 'failed' }) + const published = await createCut({ + speech, + startSeconds: 30, + endSeconds: 50, + status: 'published', + }) + const { communicator, advisor } = await createUsers() + asActor(communicator) + + mockedStartJob.mockClear() + const retried = await retrySpeechCutForActor({ cutId: failed }) + expect(retried.id).toBe(failed) + expect(retried.status).toBe('processing') + expect(mockedStartJob).toHaveBeenCalledWith(failed) + + await expect(retrySpeechCutForActor({ cutId: published })).rejects.toThrow( + SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE, + ) + + asActor(advisor) + await expect(retrySpeechCutForActor({ cutId: failed })).rejects.toThrow( + SPEECH_CUT_FORBIDDEN_MESSAGE, + ) + }) + + it('leaves the stored media behind when the cut is deleted (C183 documented orphan)', 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 { media } = await expectPublishedWithFakeFfmpeg(cutId) + + await deleteSpeechCutForActor({ cutId }) + expect(await findCutById(cutId)).toBeNull() + + const mediaRow = await payload + .findByID({ collection: 'media', id: media.id, depth: 0, overrideAccess: true }) + .catch(() => null) + expect(mediaRow).not.toBeNull() + } finally { + restoreFfmpeg() + vi.unstubAllGlobals() + } + }) }) describe('speech cut discovery (C174)', () => { diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 33677729..1fe9e11f 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -243,6 +243,22 @@ describe('campaign JSON mutation route convention', () => { expect(offenders, 'build the handler with campaignJsonMutationRoute').toEqual([]) }) + + it('guards every DELETE route under src/app with isSameOriginRequest', () => { + // C183 — the DELETE handlers cannot use the POST-only wrapper, so the + // same-origin line is hand-written again; this sweep is what keeps the next + // one from forgetting it (forgetting it fails OPEN, exactly like POST). + const offenders = walkSourceFiles(resolve(repoRoot, 'src/app/(campaign)'), ['.ts']) + .filter((file) => basename(file) === 'route.ts') + .map(repoPath) + .filter((path) => { + const source = readFileSync(resolve(repoRoot, path), 'utf8') + if (!/export (const|async function) DELETE\b/.test(source)) return false + return !/isSameOriginRequest\(/.test(source) + }) + + expect(offenders, 'call isSameOriginRequest in the DELETE handler').toEqual([]) + }) }) describe('campaign refusal messages come from shared constants', () => { diff --git a/tests/unit/speechCutCardActions.unit.spec.tsx b/tests/unit/speechCutCardActions.unit.spec.tsx new file mode 100644 index 00000000..4e4b29ba --- /dev/null +++ b/tests/unit/speechCutCardActions.unit.spec.tsx @@ -0,0 +1,89 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { routerMock } = vi.hoisted(() => ({ + routerMock: { refresh: vi.fn() }, +})) + +vi.mock('next/navigation', () => ({ useRouter: () => routerMock })) + +import { SpeechCutLibraryCardActions } from '@/components/campaign/speech/SpeechCutLibraryCardActions' +import type { SpeechCutLibraryItemViewModel, SpeechCutStatus } from '@/lib/speechCut' + +const cutView = ( + status: SpeechCutStatus, + overrides: Partial = {}, +): SpeechCutLibraryItemViewModel => ({ + id: 7, + status, + step: null, + title: 'Corte da biblioteca', + description: 'Descrição do corte', + startSeconds: 0, + endSeconds: 20, + durationSeconds: 20, + durationLabel: '20s', + publicPath: '/corte/7', + mediaUrl: null, + mediaFilename: null, + youtubeVideoId: null, + publishedAt: null, + failureMessage: status === 'failed' ? 'Não foi possível preparar o corte.' : null, + createdAtLabel: '16 set. 2026', + origin: null, + ...overrides, +}) + +const fetchMock = vi.fn() +const jsonResponse = (payload: unknown, ok = true) => ({ ok, json: async () => payload }) + +beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + vi.stubGlobal('fetch', fetchMock) + routerMock.refresh.mockReset() + fetchMock.mockReset() +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('SpeechCutLibraryCardActions (C183)', () => { + it('offers retry only on a failed cut and always offers delete', () => { + const { unmount } = render() + expect(screen.getByRole('button', { name: /Tentar novamente/ })).toBeDefined() + expect(screen.getByRole('button', { name: 'Apagar' })).toBeDefined() + unmount() + + render() + expect(screen.queryByRole('button', { name: /Tentar novamente/ })).toBeNull() + expect(screen.getByRole('button', { name: 'Apagar' })).toBeDefined() + }) + + it('keeps delete available while the cut is still processing', () => { + render() + expect(screen.queryByRole('button', { name: /Tentar novamente/ })).toBeNull() + expect(screen.getByRole('button', { name: 'Apagar' })).toBeDefined() + }) + + it('retries through the cut endpoint on the same row', async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: 'success', cut: { id: 7 } })) + render() + + fireEvent.click(screen.getByRole('button', { name: /Tentar novamente/ })) + + await waitFor(() => expect(routerMock.refresh).toHaveBeenCalled()) + expect(fetchMock).toHaveBeenCalledWith( + '/campanha/comunicacao/acervo/cortes/7/retry', + expect.objectContaining({ method: 'POST', body: JSON.stringify({ cutId: 7 }) }), + ) + }) +}) diff --git a/tests/unit/speechCutDeleteDialog.unit.spec.tsx b/tests/unit/speechCutDeleteDialog.unit.spec.tsx new file mode 100644 index 00000000..86596b58 --- /dev/null +++ b/tests/unit/speechCutDeleteDialog.unit.spec.tsx @@ -0,0 +1,103 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { routerMock } = vi.hoisted(() => ({ + routerMock: { push: vi.fn(), refresh: vi.fn() }, +})) + +vi.mock('next/navigation', () => ({ useRouter: () => routerMock })) + +import { SpeechCutDeleteDialog } from '@/components/campaign/speech/SpeechCutDeleteDialog' + +const fetchMock = vi.fn() +const jsonResponse = (payload: unknown, ok = true) => ({ ok, json: async () => payload }) + +const renderDialog = (overrides: Partial[0]> = {}) => { + render( + , + ) +} + +const openConfirm = async () => { + fireEvent.click(screen.getAllByRole('button', { name: 'Apagar' })[0]!) + return screen.findByRole('alertdialog') +} + +const confirm = () => + fireEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Apagar' })) + +beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + vi.stubGlobal('fetch', fetchMock) + routerMock.push.mockReset() + routerMock.refresh.mockReset() + fetchMock.mockReset() +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('SpeechCutDeleteDialog (C183)', () => { + it('warns about the public link only when the cut is published', async () => { + renderDialog({ status: 'published', publicPath: '/corte/123' }) + const dialog = await openConfirm() + + expect(within(dialog).getByText('Apagar este corte?')).toBeDefined() + expect(within(dialog).getByText('/corte/123')).toBeDefined() + expect(within(dialog).getByText(/deixa de funcionar para quem já recebeu/)).toBeDefined() + }) + + it('keeps only the irreversible warning when the cut is not published', async () => { + renderDialog({ status: 'unpublished', publicPath: '/corte/123' }) + const dialog = await openConfirm() + + expect(within(dialog).getByText('Esta ação não pode ser desfeita.')).toBeDefined() + expect(within(dialog).queryByText(/deixa de funcionar/)).toBeNull() + }) + + it('deletes on the right endpoint and refreshes the list', async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: 'success', deleted: true })) + renderDialog() + await openConfirm() + confirm() + + await waitFor(() => expect(routerMock.refresh).toHaveBeenCalled()) + expect(fetchMock).toHaveBeenCalledWith( + '/campanha/comunicacao/acervo/cortes/123/apagar', + expect.objectContaining({ method: 'DELETE' }), + ) + }) + + it('redirects to the list after delete when asked (detail page)', async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: 'success', deleted: true })) + renderDialog({ redirectTo: '/campanha/comunicacao/acervo/cortes' }) + await openConfirm() + confirm() + + await waitFor(() => + expect(routerMock.push).toHaveBeenCalledWith('/campanha/comunicacao/acervo/cortes'), + ) + expect(routerMock.refresh).not.toHaveBeenCalled() + }) + + it('surfaces the domain error and does not refresh', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ status: 'error', message: 'Corte não encontrado.' }, false), + ) + renderDialog() + await openConfirm() + confirm() + + expect(await screen.findByText('Corte não encontrado.')).toBeDefined() + expect(routerMock.refresh).not.toHaveBeenCalled() + }) +})