mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C183): apaga um corte da biblioteca e recupera cortes falhados (Closes #1105)
This commit is contained in:
parent
42f013317a
commit
172a0d835e
26 changed files with 1696 additions and 55 deletions
1
docs/changelog/2026-09-16-c183.md
Normal file
1
docs/changelog/2026-09-16-c183.md
Normal file
|
|
@ -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/<id>` 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/<id>` e retry de um `failed`). Issue #1105.
|
||||
169
docs/plans/biblioteca-cortes-apagar-e-falhas-impl.md
Normal file
169
docs/plans/biblioteca-cortes-apagar-e-falhas-impl.md
Normal file
|
|
@ -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/<id>` 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/<id>` 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/<id> 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/<id>/apagar` → 200, some da lista e `/corte/<id>` → 404; `advisor`/`leader` → 400 `não tem acesso ao acervo`; `POST .../<id>/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/<id>` 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/<id>` (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/<id>` já distribuído → confirmação com aviso verbatim; `afterDelete` (`SpeechCut.ts:64-68`) revalida o tag `document_speechCut:<id>`, 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/<id>` (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.
|
||||
|
|
@ -92,8 +92,31 @@
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<nav aria-label="Índice de cenas" class="flex flex-wrap gap-2 text-xs">
|
||||
<a href="#scene-a" class="rounded-full border border-border bg-card px-3 py-1.5 font-medium"
|
||||
>A · Lista desktop</a
|
||||
>
|
||||
<a
|
||||
href="#scenes-b-c"
|
||||
class="rounded-full border border-border bg-card px-3 py-1.5 font-medium"
|
||||
>B–C · Confirmações</a
|
||||
>
|
||||
<a href="#scene-d" class="rounded-full border border-border bg-card px-3 py-1.5 font-medium"
|
||||
>D · Lista mobile</a
|
||||
>
|
||||
<a href="#scene-e" class="rounded-full border border-border bg-card px-3 py-1.5 font-medium"
|
||||
>E · Detalhe desktop</a
|
||||
>
|
||||
<a href="#scene-f" class="rounded-full border border-border bg-card px-3 py-1.5 font-medium"
|
||||
>F · Detalhe publicado</a
|
||||
>
|
||||
<a href="#scene-g" class="rounded-full border border-border bg-card px-3 py-1.5 font-medium"
|
||||
>G · Detalhe mobile</a
|
||||
>
|
||||
</nav>
|
||||
|
||||
<!-- CENA A — desktop / lista -->
|
||||
<section aria-labelledby="scene-a-title">
|
||||
<section id="scene-a" aria-labelledby="scene-a-title">
|
||||
<div class="mb-3 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground">Cena A · desktop ~1280</p>
|
||||
|
|
@ -385,9 +408,11 @@
|
|||
</section>
|
||||
|
||||
<!-- CENAS B e C — diálogos sempre visíveis para o gate -->
|
||||
<section aria-labelledby="dialog-scenes-title">
|
||||
<section id="scenes-b-c" aria-labelledby="dialog-scenes-title">
|
||||
<div class="mb-3">
|
||||
<p class="text-xs font-medium text-muted-foreground">Cenas B e C · confirmação</p>
|
||||
<p class="text-xs font-medium text-muted-foreground">
|
||||
Cenas B e C · confirmação na lista e no detalhe
|
||||
</p>
|
||||
<h2 id="dialog-scenes-title" class="mt-1 text-base font-semibold">
|
||||
O aviso cresce somente quando existe link público
|
||||
</h2>
|
||||
|
|
@ -512,7 +537,7 @@
|
|||
</section>
|
||||
|
||||
<!-- CENA D — mobile -->
|
||||
<section aria-labelledby="scene-d-title">
|
||||
<section id="scene-d" aria-labelledby="scene-d-title">
|
||||
<div class="mb-3">
|
||||
<p class="text-xs font-medium text-muted-foreground">Cena D · mobile ~390</p>
|
||||
<h2 id="scene-d-title" class="mt-1 text-base font-semibold">
|
||||
|
|
@ -685,6 +710,582 @@
|
|||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA E — detalhe falhou / desktop -->
|
||||
<section id="scene-e" aria-labelledby="scene-e-title">
|
||||
<div class="mb-3 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground">Cena E · detalhe desktop ~1280</p>
|
||||
<h2 id="scene-e-title" class="mt-1 text-base font-semibold">
|
||||
A falha oferece recuperação no cabeçalho, sem esconder a saída definitiva
|
||||
</h2>
|
||||
</div>
|
||||
<p class="hidden max-w-md text-right text-xs leading-5 text-muted-foreground md:block">
|
||||
“Tentar novamente” é a ação primária. “Apagar” permanece textual, destrutivo e separado
|
||||
à direita.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="scene-shadow mx-auto h-[790px] max-w-[1280px] overflow-hidden rounded-xl border border-border bg-background"
|
||||
>
|
||||
<div class="flex h-full">
|
||||
<aside
|
||||
class="hidden w-64 shrink-0 border-r border-sidebar-border bg-sidebar p-4 lg:flex lg:flex-col"
|
||||
>
|
||||
<div class="flex h-12 items-center gap-3 border-b border-sidebar-border pb-4">
|
||||
<div
|
||||
class="grid size-8 place-items-center rounded-lg bg-primary text-sm font-bold text-primary-foreground"
|
||||
>
|
||||
S
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold">Campanha</p>
|
||||
<p class="text-xs text-muted-foreground">Mesa de trabalho</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="mt-4 space-y-1 text-sm" aria-label="Navegação ilustrativa">
|
||||
<a
|
||||
href="#"
|
||||
class="flex h-8 items-center gap-3 rounded-md px-2 text-muted-foreground"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m3 11 9-8 9 8" />
|
||||
<path d="M5 10v10h14V10" />
|
||||
</svg>
|
||||
Início
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex h-8 items-center gap-3 rounded-md bg-sidebar-accent px-2 font-medium"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m3 11 18-5v12L3 14v-3Z" />
|
||||
<path d="M11.6 16.9 13 22H7l-1.3-7" />
|
||||
</svg>
|
||||
Comunicação
|
||||
</a>
|
||||
<div class="mx-3.5 border-l border-sidebar-border px-2.5 py-0.5">
|
||||
<a href="#" class="flex h-7 items-center rounded-md px-2 text-muted-foreground"
|
||||
>Acervo de falas</a
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
class="flex h-7 items-center rounded-md bg-card px-2 font-medium text-primary ring-1 ring-border"
|
||||
>Biblioteca de cortes</a
|
||||
>
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
class="mt-auto border-t border-sidebar-border pt-4 text-xs text-muted-foreground"
|
||||
>
|
||||
Assessoria de comunicação
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<header class="flex h-16 items-center justify-between border-b border-border px-6">
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">Comunicação</p>
|
||||
<p class="text-sm font-medium">Título do corte que não foi concluído</p>
|
||||
</div>
|
||||
<div class="size-8 rounded-full bg-secondary ring-1 ring-border"></div>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto max-w-5xl p-6 lg:p-8">
|
||||
<a
|
||||
href="#"
|
||||
class="-ml-2 inline-flex min-h-10 items-center gap-2 rounded-lg px-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
Voltar aos cortes
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="mt-4 flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-5 items-center rounded-full bg-destructive px-2 text-xs text-white"
|
||||
>Falhou</span
|
||||
>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>Corte de 00:35 · criado em 15 set. 2026</span
|
||||
>
|
||||
</div>
|
||||
<h1 class="mt-2 text-lg font-medium">Título do corte que não foi concluído</h1>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M20 6v6h-6" />
|
||||
<path d="M20 12a8 8 0 1 0-2.34 5.66" />
|
||||
</svg>
|
||||
Tentar novamente
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center rounded-lg px-3 text-sm font-medium text-destructive hover:bg-red-50"
|
||||
>
|
||||
Apagar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="flex aspect-video w-full items-center justify-center rounded-xl border bg-muted/40 px-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
O arquivo do corte ainda não está disponível.
|
||||
</div>
|
||||
<section
|
||||
class="mt-6 rounded-xl border border-dashed border-border p-4 opacity-70"
|
||||
>
|
||||
<h2 class="text-sm font-medium">Texto do corte</h2>
|
||||
<p class="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
Título e descrição ficam disponíveis para edição quando o processamento
|
||||
terminar.
|
||||
</p>
|
||||
<div class="mt-4 h-10 rounded-md bg-muted"></div>
|
||||
<div class="mt-3 h-24 rounded-md bg-muted"></div>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="space-y-4">
|
||||
<section class="rounded-xl border border-border p-4">
|
||||
<h2 class="text-xs tracking-wide text-muted-foreground uppercase">
|
||||
Discurso de origem
|
||||
</h2>
|
||||
<p class="mt-1.5 text-sm text-foreground/90">Fala de origem</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Trecho 00:00–00:35 da fala</p>
|
||||
<a
|
||||
href="#"
|
||||
class="mt-2 inline-flex min-h-11 items-center text-sm underline underline-offset-4"
|
||||
>Ver fala no acervo</a
|
||||
>
|
||||
</section>
|
||||
<section class="rounded-xl border border-border p-4">
|
||||
<h2 class="text-xs tracking-wide text-muted-foreground uppercase">
|
||||
Publicação
|
||||
</h2>
|
||||
<div class="mt-2">
|
||||
<span
|
||||
class="inline-flex h-5 items-center rounded-full bg-destructive px-2 text-xs text-white"
|
||||
>Falhou</span
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
Não foi possível preparar o arquivo. Tente novamente sem refazer o corte.
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-border bg-card p-4">
|
||||
<p class="text-xs font-semibold">Regra do cabeçalho em todos os estados</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Publicado, despublicado e processando: “Apagar” no mesmo extremo direito. Falhou:
|
||||
“Tentar novamente” primário imediatamente antes de “Apagar”.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border bg-card p-4">
|
||||
<p class="text-xs font-semibold">Retry · sem confirmação</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
disabled
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground opacity-70"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-6.2-8.6" />
|
||||
</svg>
|
||||
Tentando novamente…
|
||||
</button>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>Desabilita as duas ações até concluir.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA F — detalhe publicado + confirmação -->
|
||||
<section id="scene-f" aria-labelledby="scene-f-title">
|
||||
<div class="mb-3">
|
||||
<p class="text-xs font-medium text-muted-foreground">Cena F · detalhe publicado ~1280</p>
|
||||
<h2 id="scene-f-title" class="mt-1 text-base font-semibold">
|
||||
Apagar no detalhe reutiliza exatamente a confirmação do link público
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
class="scene-shadow relative mx-auto flex min-h-[560px] max-w-[1280px] items-center justify-center overflow-hidden rounded-xl border border-border bg-background p-6"
|
||||
>
|
||||
<div class="absolute inset-0">
|
||||
<div class="flex h-16 items-center justify-between border-b border-border px-8">
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">Comunicação</p>
|
||||
<p class="text-sm font-medium">Título do corte publicado</p>
|
||||
</div>
|
||||
<div class="size-8 rounded-full bg-secondary ring-1 ring-border"></div>
|
||||
</div>
|
||||
<div class="mx-auto max-w-5xl p-8">
|
||||
<a
|
||||
href="#"
|
||||
class="-ml-2 inline-flex min-h-10 items-center gap-2 rounded-lg px-2 text-sm font-medium"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
Voltar aos cortes
|
||||
</a>
|
||||
<div class="mt-4 flex items-start justify-between gap-4 border-b border-border pb-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-5 items-center rounded-full bg-primary px-2 text-xs text-primary-foreground"
|
||||
>Publicado</span
|
||||
><span class="text-xs text-muted-foreground"
|
||||
>Corte de 00:42 · criado em 16 set. 2026</span
|
||||
>
|
||||
</div>
|
||||
<h1 class="mt-2 text-lg font-medium">Título do corte publicado</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center rounded-lg px-3 text-sm font-medium text-destructive"
|
||||
>
|
||||
Apagar
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div class="aspect-video rounded-xl border bg-stone-800"></div>
|
||||
<div class="space-y-4">
|
||||
<div class="h-32 rounded-xl border border-border bg-card"></div>
|
||||
<div class="h-40 rounded-xl border border-border bg-card"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-foreground/35"></div>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="detail-published-dialog-title"
|
||||
class="dialog-shadow relative w-full max-w-md rounded-xl border border-border bg-popover p-6 text-popover-foreground"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="grid size-9 shrink-0 place-items-center rounded-full bg-red-50 text-destructive"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4" />
|
||||
<path d="M12 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="detail-published-dialog-title" class="text-base font-semibold">
|
||||
Apagar este corte?
|
||||
</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
O link público
|
||||
<span class="font-medium text-foreground">/corte/<id></span> deixa de
|
||||
funcionar para quem já recebeu. Esta ação não pode ser desfeita.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 items-center justify-center rounded-lg border border-border bg-background px-4 text-sm font-medium"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 items-center justify-center rounded-lg bg-destructive px-4 text-sm font-medium text-white"
|
||||
>
|
||||
Apagar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
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”.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- CENA G — detalhe / mobile -->
|
||||
<section id="scene-g" aria-labelledby="scene-g-title">
|
||||
<div class="mb-3">
|
||||
<p class="text-xs font-medium text-muted-foreground">Cena G · detalhe mobile ~390</p>
|
||||
<h2 id="scene-g-title" class="mt-1 text-base font-semibold">
|
||||
A hierarquia se mantém com alvos de toque de 44 px
|
||||
</h2>
|
||||
</div>
|
||||
<div class="grid items-start gap-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<p class="mb-2 text-center text-xs font-medium text-muted-foreground">Publicado</p>
|
||||
<div
|
||||
class="scene-shadow mx-auto w-full max-w-[390px] overflow-hidden rounded-[28px] border-[6px] border-stone-800 bg-background"
|
||||
>
|
||||
<div
|
||||
class="flex h-14 items-center justify-between bg-primary px-4 text-primary-foreground"
|
||||
>
|
||||
<button aria-label="Abrir menu" class="grid size-10 place-items-center rounded-lg">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="max-w-[230px] truncate text-sm font-semibold"
|
||||
>Título do corte publicado</span
|
||||
>
|
||||
<div class="size-10"></div>
|
||||
</div>
|
||||
<div class="px-4 py-5">
|
||||
<a
|
||||
href="#"
|
||||
class="-ml-2 inline-flex min-h-11 items-center gap-2 rounded-lg px-2 text-sm font-medium"
|
||||
><svg
|
||||
aria-hidden="true"
|
||||
class="size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" /></svg
|
||||
>Voltar aos cortes</a
|
||||
>
|
||||
<div class="mt-3 border-b border-border pb-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-5 items-center rounded-full bg-primary px-2 text-xs text-primary-foreground"
|
||||
>Publicado</span
|
||||
><span class="text-xs text-muted-foreground">00:42 · 16 set. 2026</span>
|
||||
</div>
|
||||
<h1 class="mt-2 text-lg font-medium">Título do corte publicado</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 inline-flex min-h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-destructive hover:bg-red-50"
|
||||
>
|
||||
Apagar
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-5 aspect-video rounded-xl border bg-stone-800"></div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||
<button class="min-h-11 rounded-lg border border-border text-xs font-medium">
|
||||
Baixar</button
|
||||
><button class="min-h-11 rounded-lg border border-border text-xs font-medium">
|
||||
Copiar link
|
||||
</button>
|
||||
</div>
|
||||
<section class="mt-5 rounded-xl border border-border p-4">
|
||||
<h2 class="text-xs tracking-wide text-muted-foreground uppercase">
|
||||
Discurso de origem
|
||||
</h2>
|
||||
<p class="mt-2 text-sm">Fala de origem</p>
|
||||
<a
|
||||
href="#"
|
||||
class="mt-2 inline-flex min-h-11 items-center text-sm underline underline-offset-4"
|
||||
>Ver fala no acervo</a
|
||||
>
|
||||
</section>
|
||||
</div>
|
||||
<nav
|
||||
aria-label="Navegação inferior ilustrativa"
|
||||
class="grid h-16 grid-cols-5 border-t border-border bg-background px-2 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<span class="grid place-items-center">Início</span
|
||||
><span class="grid place-items-center">Municípios</span
|
||||
><span class="grid place-items-center">Atualizações</span
|
||||
><span class="grid place-items-center">Agenda</span
|
||||
><span class="grid place-items-center text-primary">Mais</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-center text-xs font-medium text-muted-foreground">Falhou</p>
|
||||
<div
|
||||
class="scene-shadow mx-auto w-full max-w-[390px] overflow-hidden rounded-[28px] border-[6px] border-stone-800 bg-background"
|
||||
>
|
||||
<div
|
||||
class="flex h-14 items-center justify-between bg-primary px-4 text-primary-foreground"
|
||||
>
|
||||
<button aria-label="Abrir menu" class="grid size-10 place-items-center rounded-lg">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="max-w-[230px] truncate text-sm font-semibold"
|
||||
>Corte não concluído</span
|
||||
>
|
||||
<div class="size-10"></div>
|
||||
</div>
|
||||
<div class="px-4 py-5">
|
||||
<a
|
||||
href="#"
|
||||
class="-ml-2 inline-flex min-h-11 items-center gap-2 rounded-lg px-2 text-sm font-medium"
|
||||
><svg
|
||||
aria-hidden="true"
|
||||
class="size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" /></svg
|
||||
>Voltar aos cortes</a
|
||||
>
|
||||
<div class="mt-3 border-b border-border pb-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-5 items-center rounded-full bg-destructive px-2 text-xs text-white"
|
||||
>Falhou</span
|
||||
><span class="text-xs text-muted-foreground">00:35 · 15 set. 2026</span>
|
||||
</div>
|
||||
<h1 class="mt-2 text-lg font-medium">Título do corte que não foi concluído</h1>
|
||||
<div class="mt-4 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M20 6v6h-6" />
|
||||
<path d="M20 12a8 8 0 1 0-2.34 5.66" /></svg
|
||||
>Tentar novamente
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-destructive hover:bg-red-50"
|
||||
>
|
||||
Apagar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-5 flex aspect-video items-center justify-center rounded-xl border bg-muted/40 px-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
O arquivo do corte ainda não está disponível.
|
||||
</div>
|
||||
<section class="mt-5 rounded-xl border border-border p-4">
|
||||
<h2 class="text-xs tracking-wide text-muted-foreground uppercase">Publicação</h2>
|
||||
<div class="mt-2">
|
||||
<span
|
||||
class="inline-flex h-5 items-center rounded-full bg-destructive px-2 text-xs text-white"
|
||||
>Falhou</span
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
Não foi possível preparar o arquivo. Tente novamente sem refazer o corte.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<nav
|
||||
aria-label="Navegação inferior ilustrativa"
|
||||
class="grid h-16 grid-cols-5 border-t border-border bg-background px-2 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<span class="grid place-items-center">Início</span
|
||||
><span class="grid place-items-center">Municípios</span
|
||||
><span class="grid place-items-center">Atualizações</span
|
||||
><span class="grid place-items-center">Agenda</span
|
||||
><span class="grid place-items-center text-primary">Mais</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 rounded-xl border border-border bg-card p-4 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
Ao tocar “Apagar”, o diálogo ocupa a largura disponível com
|
||||
<span class="font-medium text-foreground">mx-4</span> 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.
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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<NextResponse<SpeechCutDeleteResponse>> => {
|
||||
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<SpeechCutDeleteResponse>({ 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SpeechCutStatusBadge status={cut.status} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Corte de {cut.durationLabel}
|
||||
{cut.createdAtLabel ? <> · criado em {cut.createdAtLabel}</> : null}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mt-2 text-lg font-medium">{cut.title}</h1>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 max-sm:w-full">
|
||||
{cut.status === 'failed' ? (
|
||||
<SpeechCutRetryButton cutId={cut.id} className="min-h-11 max-sm:w-full" />
|
||||
) : null}
|
||||
<SpeechCutDeleteDialog
|
||||
cutId={cut.id}
|
||||
status={cut.status}
|
||||
publicPath={cut.publicPath}
|
||||
redirectTo={CAMPAIGN_COMMUNICATION_CORTES}
|
||||
triggerClassName="min-h-11 max-sm:w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<SpeechCutPlayer cut={cut} />
|
||||
|
||||
<div className="mt-3">
|
||||
<SpeechCutLibraryShareActions cut={cut} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<SpeechCutTextEditor
|
||||
cutId={cut.id}
|
||||
initialTitle={cut.title}
|
||||
initialDescription={cut.description}
|
||||
disabled={notReady}
|
||||
/>
|
||||
{notReady ? (
|
||||
<section
|
||||
className="rounded-xl border border-dashed border-border p-4 opacity-70"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<h2 className="text-sm font-medium">Texto do corte</h2>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
Título e descrição ficam disponíveis para edição quando o processamento
|
||||
terminar.
|
||||
</p>
|
||||
<div className="mt-4 h-10 rounded-md bg-muted" />
|
||||
<div className="mt-3 h-24 rounded-md bg-muted" />
|
||||
</section>
|
||||
) : (
|
||||
<SpeechCutTextEditor
|
||||
cutId={cut.id}
|
||||
initialTitle={cut.title}
|
||||
initialDescription={cut.description}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SpeechCutRetryResponse>({ status: 'success', cut })
|
||||
},
|
||||
)
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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/<id>` (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<SpeechCutViewModel> => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
132
src/components/campaign/speech/SpeechCutDeleteDialog.tsx
Normal file
132
src/components/campaign/speech/SpeechCutDeleteDialog.tsx
Normal file
|
|
@ -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/<id>` 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<string | null>(null)
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { ok, payload } = await deleteCampaignJson<SpeechCutDeleteResponse>(
|
||||
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 (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn('text-destructive hover:bg-red-50', triggerClassName)}
|
||||
>
|
||||
Apagar
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-9 shrink-0 place-items-center rounded-full bg-red-50 text-destructive">
|
||||
<CircleAlertIcon className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<AlertDialogTitle>Apagar este corte?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="mt-2">
|
||||
{status === 'published' ? (
|
||||
<>
|
||||
O link público <span className="font-medium text-foreground">{publicPath}</span>{' '}
|
||||
deixa de funcionar para quem já recebeu. Esta ação não pode ser desfeita.
|
||||
</>
|
||||
) : (
|
||||
'Esta ação não pode ser desfeita.'
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel type="button" className="min-h-11" disabled={submitting}>
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="min-h-11 bg-destructive text-white hover:bg-destructive/90"
|
||||
disabled={submitting}
|
||||
onClick={() => void confirmDelete()}
|
||||
>
|
||||
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
Apagar
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertDescription className="text-xs">{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }) => (
|
||||
<article
|
||||
|
|
@ -42,14 +38,6 @@ export const SpeechCutLibraryCard = ({ cut }: { cut: SpeechCutLibraryItemViewMod
|
|||
<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={campaignSpeechCutDetailHref(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>
|
||||
<SpeechCutLibraryCardActions cut={cut} />
|
||||
</article>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{cut.status === 'failed' ? (
|
||||
<SpeechCutRetryButton cutId={cut.id} className="min-h-11 w-full sm:min-h-10 sm:w-auto" />
|
||||
) : null}
|
||||
<Button asChild variant="outline" className="min-h-10 flex-1 sm:flex-none">
|
||||
<Link href={campaignSpeechCutDetailHref(cut.id)}>
|
||||
<PlayIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir corte
|
||||
</Link>
|
||||
</Button>
|
||||
{cut.status === 'published' ? (
|
||||
<CopyLinkButton url={cut.publicPath} variant="ghost" className="flex-1 sm:flex-none" />
|
||||
) : null}
|
||||
<SpeechCutDeleteDialog
|
||||
cutId={cut.id}
|
||||
status={cut.status}
|
||||
publicPath={cut.publicPath}
|
||||
triggerClassName={deleteTriggerClassName(cut.status)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -135,7 +135,7 @@ export const SpeechCutPublicationPanel = ({ cutId, status }: SpeechCutPublicatio
|
|||
|
||||
{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.
|
||||
Não foi possível preparar o arquivo. Tente novamente sem refazer o corte.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
76
src/components/campaign/speech/SpeechCutRetryButton.tsx
Normal file
76
src/components/campaign/speech/SpeechCutRetryButton.tsx
Normal file
|
|
@ -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<string | null>(null)
|
||||
|
||||
const retry = async () => {
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<SpeechCutRetryResponse>(
|
||||
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 (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
className={cn('min-h-10', className)}
|
||||
disabled={submitting}
|
||||
onClick={() => void retry()}
|
||||
>
|
||||
{submitting ? (
|
||||
<Spinner data-icon="inline-start" aria-hidden="true" />
|
||||
) : (
|
||||
<RotateCcwIcon data-icon="inline-start" aria-hidden="true" />
|
||||
)}
|
||||
{submitting ? 'Tentando novamente…' : 'Tentar novamente'}
|
||||
</Button>
|
||||
{error ? (
|
||||
<p role="alert" className="basis-full text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <TResponse>(
|
|||
|
||||
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 <TResponse>(
|
||||
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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof speechCutCutIdRequestSchema>
|
||||
|
||||
/** AI title/description suggestion for the picked window. */
|
||||
export const speechCutSuggestionRequestSchema = z.object({
|
||||
speechId: positiveRelationshipId,
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<CampaignJsonErrorBody> => {
|
||||
|
|
@ -97,7 +103,7 @@ export const campaignJsonMutationRoute =
|
|||
async (request: Request): Promise<NextResponse<TResponse | CampaignJsonErrorBody>> => {
|
||||
if (!isSameOriginRequest(request)) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: 'Requisição inválida.' },
|
||||
{ status: 'error', message: CAMPAIGN_JSON_INVALID_REQUEST_MESSAGE },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
|
|||
})
|
||||
|
||||
import {
|
||||
deleteSpeechCutForActor,
|
||||
getSpeechCutStatusForActor,
|
||||
retrySpeechCutForActor,
|
||||
saveSpeechCutForActor,
|
||||
setSpeechCutPublishedForActor,
|
||||
suggestSpeechCutMetadataForActor,
|
||||
|
|
@ -37,6 +39,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'
|
||||
|
|
@ -215,6 +218,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) => {
|
||||
|
|
@ -827,6 +841,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)', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
89
tests/unit/speechCutCardActions.unit.spec.tsx
Normal file
89
tests/unit/speechCutCardActions.unit.spec.tsx
Normal file
|
|
@ -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> = {},
|
||||
): 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(<SpeechCutLibraryCardActions cut={cutView('failed')} />)
|
||||
expect(screen.getByRole('button', { name: /Tentar novamente/ })).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: 'Apagar' })).toBeDefined()
|
||||
unmount()
|
||||
|
||||
render(<SpeechCutLibraryCardActions cut={cutView('published')} />)
|
||||
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(<SpeechCutLibraryCardActions cut={cutView('processing')} />)
|
||||
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(<SpeechCutLibraryCardActions cut={cutView('failed')} />)
|
||||
|
||||
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 }) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
103
tests/unit/speechCutDeleteDialog.unit.spec.tsx
Normal file
103
tests/unit/speechCutDeleteDialog.unit.spec.tsx
Normal file
|
|
@ -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<Parameters<typeof SpeechCutDeleteDialog>[0]> = {}) => {
|
||||
render(
|
||||
<SpeechCutDeleteDialog cutId={123} status="published" publicPath="/corte/123" {...overrides} />,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue