feat(C222): apagar uma peça na Central de Conteúdos (Closes #1330)

Hard delete por peça (sem lixeira/lote/telemetria) em qualquer estado, com confirmação que nomeia o link público só quando publicado; access próprio da vertical + admin (advisor/leader fail-closed), cascata da mídia pós-commit e bust da tag contentPieces no afterDelete; máquina do diálogo extraída no 3º call site (C183/C199/C222).
This commit is contained in:
Francisco Solla 2026-09-24 23:25:43 -03:00
parent 3cdd1cd8fb
commit 63bd370b6c
24 changed files with 1041 additions and 226 deletions

View file

@ -0,0 +1 @@
**C222 — Central de Conteúdos: apagar uma peça (2026-09-24):** a assessoria que cataloga passa a poder remover uma peça da Central — errada, duplicada ou vencida — sem depender do admin do Payload, reabrindo de propósito o anti-goal declarado no C211 ("sem delete na Central; só admin"), exatamente como o C183 reabriu o mesmo anti-goal nos cortes. Apagar é hard delete (sem lixeira/desfazer/restauração, sem lote, sem telemetria) e existe em qualquer estado, inclusive `processando` (a saída de quem quer cancelar um processamento travado) e `falhou`; a confirmação é explícita e nomeia o contrato público apenas quando ele vai quebrar: peça `publicado` avisa que `O link público /conteudos/<slug> deixa de funcionar para quem já recebeu` (despublicar continua o gesto reversível, não é pré-requisito), e os demais estados mantêm só "Esta ação não pode ser desfeita.". Quem apaga é a mesma vertical que escreve/publica — `communicator`, `coordinator`, `candidate` — mais o admin do Payload; `advisor`/`leader` negados fail-closed (testado no collection e na rota). Donos: predicado próprio `canDeleteContentPiece` em `utilities/access/contentPieces.ts` (nunca alias do update, que um alargamento futuro poderia conceder) ligado ao `access.delete` do `ContentPiece`; a collection ganha `afterDelete` bustando a tag `contentPieces` no mesmo seam do `afterChange` (sem isso a pública cacheada sem TTL serviria a peça fantasma em `/conteudos/<slug>` e `/midia`); a action `deleteContentPieceForActor` apaga a row dentro de `withPayloadTransaction` com `overrideAccess: false` e `user: actor`, lê o `media` **do doc retornado pelo próprio delete** (fecha a janela em que o job poderia anexar arquivo entre um pré-read e o commit) e remove a `contentMedia` **pós-commit**, best-effort, via `onPayloadTransactionCommit` (mesmo shape do C199: um soluço de storage não derruba o delete que a pessoa pediu); o job de processamento tolera a row sumida (o catch existente engole o `NotFound`, provado por int que roda `runContentPieceJob` depois do delete) e os contadores anônimos de circulação (`contentEvent`, C213, `subjectId` texto sem FK) ficam intactos — órfãos inertes por contrato. Rota nova `DELETE /campanha/comunicacao/conteudos/[id]/apagar` no shape pinado das irmãs (`isSameOriginRequest` → 403, `strictDecimalInteger` → 400, `campaignJsonMutationErrorResponse` com as constantes `CONTENT_PIECE_*`), wire type `ContentPieceDeleteResponse` e href `campaignContentPieceDeleteHref`. UI (Impeccable B, porte classe-a-classe do artefato aprovado `docs/plans/central-conteudos-apagar-peca-ui-design.html`, cenas A–H): "Apagar" ghost destrutivo entra ao lado da próxima ação na lista desktop e no card mobile (abaixo do Reprocessar quando a peça falhou) e fecha a fila de ações do topo da ficha (com redirect para a Central), e o diálogo vive na máquina compartilhada `CampaignDeleteDialog` — extraída no terceiro call site (C183 cortes, C199 gravações, C222 peças) com markup/classes idênticos e adapters de política, os specs unitários do C183/C199 passando sem edição; a tabela foi rebalanceada para as larguras do artefato (23/14/12/25/26) para o "Apagar" caber sem colisão, e o row VM ganhou `publicPath` (select `slug` na lista) para o aviso nomeado. Sem migration, sem collection nova, sem Consent/PII, sem mudança de URL pública (a peça apenas deixa de existir). Verificação: int no `contentPiece` (access nos 5 papéis + admin + anônimo, cascata com poll da mídia, delete nos quatro estados, worker sem derrubar, evento preservado, spy do `revalidateTag`, `publicPath` publicado/rascunho), unit do diálogo novo + adapters, e2e no `campaignSpeechAcervo` (delete HTTP ponta a ponta com `/conteudos/<slug>` e `/midia` caindo para 404 anônimo e advisor negado) e os e2e da mesma superfície (`campaignSpeechAcervo` 40/40, `campaignSpeechCut`, `campaignReel`) rodados localmente com `--workers=1`, `pnpm gate:fast` — a diff é high-risk (access/schemas) e o PR roda o conjunto curado. Crítica final do `designer` (trigger c) certificada em tier primário (`Design tier: openai/gpt-5.6-sol`) após uma rodada de ajustes no estado de erro do diálogo (alerta entre descrição e ações, com ícone), contra screenshots 390/1280 dos estados A–H; os defers (3ª cópia do envelope DELETE, peso do "Reprocessar" vs artefato C211) ficaram anotados com gatilho no `*-impl.md`.

View file

@ -0,0 +1,185 @@
# Impl: C222 — Central de Conteúdos — apagar uma peça
Status: aprovado
Atualizado em: 2026-09-24
Issue: #1330
Intenção: docs/plans/central-conteudos-apagar-peca.md
Appetite restante: herdado (~1 dia eng) — nenhum consumo; o item cabe: um predicado de delete, uma action com cascata de mídia pós-commit, um hook de cache, uma rota DELETE, uma máquina de diálogo extraída no terceiro call site + adapters, o `publicPath` no row VM e testes.
## Leitura da intenção
- **Outcome:** em `/campanha/comunicacao/conteudos` (lista) e `/campanha/comunicacao/conteudos/[id]` (ficha), `communicator`/`coordinator`/`candidate` apagam uma peça com confirmação explícita — avisando o link público `/conteudos/<slug>` quando `publicado` — e a peça some da Central, some da pública e o arquivo privado exclusivo sai junto. `advisor`/`leader` negados (fail-closed); admin do Payload segue podendo apagar.
- **O que NÃO negociar:** hard delete apenas (sem lixeira/desfazer/restauração, sem lote, sem telemetria/métricas de exclusão); apagar disponível em **qualquer** estado (`processando` inclusive) com o aviso do link só quando `publicado`; literais de diálogo do plano de intenção (`Apagar esta peça?` · `Cancelar`/`Apagar` · corpo publicado/não publicado); contadores de circulação (`ContentEvent`, `subjectId` texto sem FK) **não** são limpos; despublicar continua o gesto reversível e não é pré-requisito; mídia privada exclusiva da peça sai junto (sem resíduo); UI portada do artefato aprovado, classe-a-classe.
- **O que reavaliar (hipóteses do explorador que a engenharia confirma ou corrige):**
- O `delete` da collection é `payloadAdminOnly` (`ContentPiece.ts:242-250`) → vira predicado novo (Decisão 1), não alias.
- O diálogo do C183 **não** cobre a cascata de mídia; a action modela o C199 (`recording.ts:232-270`) e não o C183 (`deleteSpeechCutForActor`, que deixa mídia órfã). C183 é o molde do **diálogo**, não da limpeza (Decisão 2).
- A lista pública é cacheada sob a tag `contentPieces` e só o `afterChange` a busta (`ContentPiece.ts:90-93,258`; `contentPieceReads.ts:62-91`) → precisa `afterDelete` (Decisão 3).
- O VM de linha **não** carrega `slug`/`publicPath` e o select da lista não pede `slug` (`contentPiecePageData.ts:34-49`) → precisa para o aviso nomeado (Decisão 4).
- O job engole a row sumida no catch externo (`contentPieceJob.ts:200-311`; `failContentPiece` em `:87-97`) — **não** precisa de guard novo; precisa de teste que prove (Fase 2).
- `deleteSpeechCutForActor` (`speech.ts:348-366`) confirma o anti-modelo: single-collection, mídia órfã — não seguir.
## Abordagem recomendada
```mermaid
flowchart LR
subgraph UI["Superfícies — artefato cenas A–D"]
L["Lista: ContentPieceTable / ContentPieceCardList<br/>Abrir | Reprocessar + Apagar (todos os estados)"]
F["Ficha [id]: fila de ações do topo<br/>Publicar/Despublicar + Apagar"]
end
L --> Dlg
F --> Dlg
Dlg["ContentPieceDeleteDialog (cenas E–H)<br/>AlertDialog + deleteCampaignJson"] -->|"DELETE .../conteudos/:id/apagar"| R
R["route.ts novo<br/>isSameOriginRequest → strictDecimalInteger"] --> A
A["deleteContentPieceForActor<br/>gate communicationCatalog + not found"] --> Tx
Tx["withPayloadTransaction<br/>payload.delete(contentPiece, select media)<br/>access canDeleteContentPiece"] -->|commit| M
Tx -->|afterDelete| C
M["onPayloadTransactionCommit<br/>delete contentMedia, overrideAccess true, best-effort"] --> S3["objeto privado sai do storage"]
C["revalidateContentPiecesListing()<br/>tag contentPieces"] --> Pub["/conteudos e /conteudos/slug/midia<br/>somem; link vira 404"]
```
**Opções consideradas:** A (recomendada: predicado próprio + cascata pós-commit C199 + `afterDelete` no seam do cache + rota DELETE C183/C199 + máquina de diálogo extraída) | B (leftovers: alias de update, hook `beforeDelete`, bust só na action, terceira cópia do diálogo) | C (caminhos laterais: delete só-admin com bypass no action, sem bust, path via wire VM).
**Recomendação:** A — cada efeito tem um dono nomeado (access no collection, cascata no action, cache no hook da collection, transporte na família `[id]/*`, UI no artefato) e o terceiro call site do diálogo vira extração, não twin.
**Rejeitadas:** B e C detalhadas por decisão abaixo.
### Decisões de engenharia
**1. Autorização — novo `canDeleteContentPiece`**
- **Opções:** A) predicado próprio em `src/utilities/access/contentPieces.ts`, molde `canDeleteSpeechCut`/`canDeleteRecording` (`isPayloadAdmin` OU `canReadCommunicationCatalog`) | B) reusar/aliasar `canUpdateContentPiece` | C) manter `payloadAdminOnly` e autorizar só no action/rota.
- **Recomendação:** **A** — exportado pelo barrel `campaignAccess.ts:188-192` e ligado em `ContentPiece.access.delete` (`ContentPiece.ts:242-250`), com o comentário atualizado (o anti-goal do C211 é reaberto de propósito; a confirmação passa a ser o freio). O action ainda repete o gate fresco para responder a mensagem de domínio (`CONTENT_PIECE_FORBIDDEN_MESSAGE`); o access do collection é a barreira final (REST/GraphQL) e é quem sustenta a negação de `advisor`/`leader` fail-closed.
- **Rejeitadas:** **B** — predicado de update como autoridade de delete é decisão silenciosa de segurança (um alargamento futuro de update concederia delete); o próprio arquivo documenta o padrão de predicado próprio. **C** — `overrideAccess: true` sem dono no collection é o caminho paralelo proibido e some com a auditoria do admin.
**2. Cascata de mídia e transação**
- **Opções:** A) action abre `withPayloadTransaction`, apaga a peça com `overrideAccess: false` e `select: { media: true }`, e agenda o delete da `contentMedia` pós-commit com `onPayloadTransactionCommit` + `overrideAccess: true` best-effort | B) hook `beforeDelete` na `ContentPiece` cascateando a mídia (molde `deleteSpeechAssets`, `Speech.ts:65-97`) | C) deixar a mídia órfã (molde C183).
- **Recomendação:** **A** — espelha `deleteRecordingForActor` (`recording.ts:232-270`), que já resolveu este exato problema no mesmo domínio: o storage não transaciona, então a limpeza não pode derrubar o delete que a pessoa pediu. Ler o `media` **do doc retornado pelo próprio delete** (o `deleteByID` aceita `select`) fecha a janela em que o job anexa mídia entre a leitura e o commit do delete; a limpeza pós-commit continua best-effort (`catch(() => undefined)`), com o comentário de bypass intencional igual ao C199. Nada muda em `ContentMedia.ts` (`delete: canUpdateContentPiece` fica; a limpeza não é uma nova autorização, é consequência da peça que o ator podia apagar).
- **Rejeitadas:** **B** — a remoção do objeto S3 não é transacional; um soluço de storage faria o delete da peça falhar e poderia deixar row/objeto inconsistentes (o C199 já tinha recusado esse caminho). **C** — contraria o aceite de produto (mídia é exclusiva da peça; resíduo não faz sentido) e é a dívida que o C183 registrou.
**3. Bust do cache público**
- **Opções:** A) `afterDelete` na `ContentPiece` chamando `revalidateContentPiecesListing()` | B) bust inline só no action | C) não bustar (confiar em TTL).
- **Recomendação:** **A** — o seam já existe e é único: a pública lê `unstable_cache` sob a tag `contentPieces` (`contentPieceReads.ts:62-91`) e o helper `revalidateContentPiecesListing` já é exportado (`documents.ts:53-54`). Entra um `CollectionAfterDeleteHook` tipado (o `afterChange` atual permanece) — assim qualquer caminho de delete (action, admin, API) mata a peça fantasma em `/conteudos/<slug>` e `/midia`. `revalidateTagSafely` já cobre o invariante fora de request scope (fixtures/e2e/seeds).
- **Rejeitadas:** **B** — delete pelo admin do Payload ou por fixture não passaria pela action e a peça ficaria fantasma indefinidamente. **C** — não existe TTL nessa leitura; o risco declarado na intenção é exatamente a peça fantasma.
**4. Superfície da ação, rota DELETE e máquina do diálogo**
- **Opções:** A) uma rota `DELETE .../conteudos/[id]/apagar/route.ts` (molde C183/C199) + um diálogo client; extrair a máquina comum para `src/components/campaign/shared/CampaignDeleteDialog.tsx` e reduzir `SpeechCutDeleteDialog`/`RecordingDeleteDialog`/`ContentPieceDeleteDialog` a adapters de política (endpoint, copy, trigger, `redirectTo`), com markup/classe idênticos | B) terceira cópia literal do diálogo | C) parametrizar o arquivo do C183 no lugar e importá-lo da superfície de conteúdos | D) Server Action no lugar da rota JSON.
- **Recomendação:** **A** — "Apagar" entra na lista (desktop e cards) e na fila de ações do topo da ficha, sempre por último (cenas A–D). A rota repete o shape pinado: `isSameOriginRequest` → 403, `strictDecimalInteger` → 400, `campaignJsonMutationErrorResponse` (exportado de `campaignJsonMutationRoute.ts:57-80`, não reimplementado), `safeMessages` com as constantes `CONTENT_PIECE_*` e `dynamic = 'force-dynamic'`. O diálogo é o do artefato E–H e o terceiro call site é o gatilho da regra "edit the owner, don't twin": extrai-se a **máquina** (estado `submitting`/`error`, `deleteCampaignJson`, `router.push`/`refresh`, estrutura `AlertDialog` + `Spinner` + `Alert` inline) mantendo o markup do C183/C199 byte-a-byte; os adapters preservam nomes e props, então call sites e specs unitários existentes não mudam. O novo componente em `shared/` ganha prefixo no manifesto e2e (`scripts/lib/e2e-affected-manifest.mjs`, bloco da vertical em `:463-506`, specs `['campaignSpeechAcervo', 'campaignSpeechCut', 'campaignReel']`).
- **Rejeitadas:** **B** — gêmeo proibido pelo engineering-standards (máquina idêntica, só a política difere). **C** — acopla o módulo do acervo de falas à Central de Conteúdos e esconde o dono. **D** — a vertical já tem o contrato JSON + guard de origem pinado pelo sweep (`codebaseConventions.unit.spec.ts:263`); Server Action dividiria envelope e mensagens de domínio.
- **Condição de aceite da extração:** classes e testes do C183/C199 passam sem edição; se a paridade de markup quebrar, o fallback é a opção B com débito registrado — nunca improvisar estrutura visual.
**5. Fonte do `publicPath` para o aviso do link**
- **Opções:** A) o row VM composto ganha `publicPath: string | null`, calculado no server (`contentPiecePageData.ts`) com `contentPiecePublicPath` + `slug: true` no select da lista | B) adicionar `slug`/`publicPath` ao VM de wire (`ContentPieceViewModel`) | C) o diálogo busca o slug numa rota nova.
- **Recomendação:** **A** — `ContentPieceViewRecord` ganha `slug?: string | null`, `ContentPieceRowViewModel` (`contentPieceCirculation.ts:64`) ganha `publicPath`, e `toContentPieceRow` faz `record.slug ? contentPiecePublicPath(record.slug) : null`. Só as páginas precisam do dado; o detalhe já seleciona `slug` (`contentPiecePageData.ts:52-65`), a lista passa a selecionar. O diálogo mostra o nome do link só quando `status === 'publicado' && publicPath`; senão mantém o corpo genérico (cena F).
- **Rejeitadas:** **B** — `src/lib/contentPiece.ts` importar `src/lib/contentPieceCatalog.ts` criaria ciclo (o catalog já importa o vocabulary; o gate `cycles` recusa) e o campo poluiria os contratos de upload/status/retry/publicação. **C** — request extra para dado que a página já tem, com estado de erro novo no caminho de falha.
### Decisões baratas (com gatilho de revisitação)
| Decisão | Escolha | Gatilho de revisitação |
| ----------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| Ícone do trigger | `Trash2Icon` (padrão do campaign; o artefato declara "sem SVG próprio") | se o `designer` pedir o glifo próprio (trigger d) |
| Copy do erro do diálogo | literal `Não foi possível apagar a peça.` (cena G) em constante local do adapter, como C183/C199 | se surgir 2º consumidor da mensagem → constante em `schemas/contentPiece.ts` |
| Pós-delete | ficha redireciona para `CAMPAIGN_COMMUNICATION_CONTEUDOS`; lista faz `router.refresh()` | se a lista ganhar estado que invalide o retorno |
| Título com `prose` | portar o reset do C199 (`border-b-0 pb-0`) se o container aplicar prose na ficha | nenhum |
| Guard no job | nenhuma mudança no `contentPieceJob` (catch existente cobre) | se o teste provar que o job derruba → guard `findByID(...).catch(() => null)` |
| Objeto S3 órfão por rollback do job | fora de escopo (só o caso commitado é limpo) | se resíduo aparecer no bucket → GC/varredura em item próprio |
### Componentes / mudanças
**Servidor / acesso**
- **`canDeleteContentPiece`** (`src/utilities/access/contentPieces.ts`): predicado próprio (admin Payload OU `canReadCommunicationCatalog`), reusando `isPayloadAdmin`/`getFreshCampaignUser` de `access/shared`; re-export no barrel `src/utilities/campaignAccess.ts:188-192`.
- **`ContentPiece`** (`src/collections/ContentPiece.ts`): `access.delete = canDeleteContentPiece` (substitui `payloadAdminOnly`; remover o import se ficar sem uso); `afterDelete: [revalidateContentPieceListingAfterDelete]` — hook tipado novo que chama `revalidateContentPiecesListing()`; o `afterChange` existente fica.
- **Schema** (`src/lib/schemas/contentPiece.ts`): `contentPieceDeleteRequestSchema` (`{ contentPieceId: z.number().int().positive() }`), molde `recordingDeleteRequestSchema`.
- **Action** (`src/app/(campaign)/campanha/actions/contentPieces.ts`): `deleteContentPieceForActor({ contentPieceId })` — parse → gate fresco `canReadCommunicationCatalog` → `loadContentPieceForActor` (404) → `withPayloadTransaction` → `payload.delete({ collection:'contentPiece', id, select:{ media:true }, user: actor, overrideAccess:false, req })` → `onPayloadTransactionCommit(transactionID, () => void payload.delete({ collection: CONTENT_MEDIA_SLUG, id: mediaId, overrideAccess:true }).catch(() => undefined))` → `{ deleted: true }`.
- **Sem migration:** access/hooks/select não mudam schema; `slug` já existe (C211). Não rodar `migrate:create`; `pnpm migrate:status` deve seguir limpo. `ContentMedia.ts` intocado. Sem `Consent` (nenhuma PII nova).
**Rota / contrato**
- **`DELETE`** (`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/apagar/route.ts`): novo, cópia do shape C183/C199 — `isSameOriginRequest` → 403 com `CAMPAIGN_JSON_INVALID_REQUEST_MESSAGE`; `strictDecimalInteger(id)` → 400; `deleteContentPieceForActor`; sucesso `{ status:'success', deleted:true }`; erro `campaignJsonMutationErrorResponse` com `safeMessages: [CONTENT_PIECE_FORBIDDEN_MESSAGE, CONTENT_PIECE_NOT_FOUND_MESSAGE]` e `genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE`; `dynamic = 'force-dynamic'`.
- **Tipos** (`.../conteudos/types.ts`): `ContentPieceDeleteResponse = { status:'success'; deleted:true } | { status:'error'; message }`.
- **`campaignContentPieceDeleteHref(id)`** (`src/lib/campaignPaths.ts`, junto de `retry`/`publicacao`): `${campaignContentPieceDetailHref(id)}/apagar`.
**UI (Impeccable B — portar o artefato A–H; sem dispatch: cobertura completa)**
- **`CampaignDeleteDialog`** (`src/components/campaign/shared/CampaignDeleteDialog.tsx`, client, novo): a máquina extraída — trigger slot via `AlertDialogTrigger asChild`, título/descrição/erro/`redirectTo`/`confirmLabel`/overrides de classe como props; `deleteCampaignJson` com tipo estrutural de resposta; `Spinner` + `Alert` inline; nunca toast.
- **Adapters**: `SpeechCutDeleteDialog`/`RecordingDeleteDialog` refatorados para renderizar a máquina com a política atual (markup e props idênticos); **`ContentPieceDeleteDialog`** (novo, `src/components/campaign/content/`) com título `Apagar esta peça?`, corpo publicado `O link público <publicPath> deixa de funcionar para quem já recebeu. Esta ação não pode ser desfeita.`, corpo genérico `Esta ação não pode ser desfeita.`, botões `Cancelar`/`Apagar`, trigger `Button variant="ghost"` destructive + `Trash2Icon` + `min-h-11`.
- **`ContentPieceTable.tsx:123-145`**: célula "Próxima ação" vira `flex items-center justify-end gap-2` — Abrir (outline) ou Reprocessar (primário) + Apagar (ghost), cena A.
- **`ContentPieceCardList.tsx:75-81`**: cena B — sem retry: `grid grid-cols-[1fr_auto]` (Abrir + Apagar); com retry: Reprocessar full width e Apagar full width abaixo.
- **`[id]/page.tsx:113-122`**: agrupar `ContentPiecePublicationButton` + `ContentPieceDeleteDialog` no `flex ... justify-end gap-2` do topo, Apagar por último (cenas C–D), com `redirectTo={CAMPAIGN_COMMUNICATION_CONTEUDOS}`. **Não** realocar `Anexar` nem o Reprocessar do painel de falha (divergências pré-existentes do C211; o artefato manda apenas "a ação definitiva fecha a fila do topo"). Se isso impedir o port na ficha mobile, é trigger (b) → despachar o `designer` antes do markup.
- **Sem** trigger (a): o artefato cobre lista desktop/mobile (A/B), ficha desktop/mobile (C/D), diálogos publicado/genérico (E/F), erro e envio (G/H). Sem trigger (d): o artefato declara que os ícones seguem o Lucide.
**Testes**
- **Unit**: novo `tests/unit/contentPieceDeleteDialog.unit.spec.tsx` (molde `speechCutDeleteDialog.unit.spec.tsx`: aviso nomeado só publicado; DELETE na URL certa; `refresh` na lista; `push` na ficha; erro de domínio sem refresh); specs do C183/C199 rodam sem edição (condição da extração).
- **Int** (`tests/int/contentPiece.int.spec.ts`): acesso ao delete na collection (communicator/coordinator/candidate ok; advisor/leader e anônimo negados); `deleteContentPieceForActor` remove a row e a mídia (poll, molde `recording.int.spec.ts:731-758`); apagar em `processando`/`falhou`/`rascunho`/`publicado`; depois do delete, `runContentPieceJob(payload, id)` resolve e não recria mídia (worker não derruba); `contentEvent` da peça permanece; `revalidateTag` chamado com a tag `contentPieces` (spy no mock de `next/cache`).
- **E2E** (`tests/e2e/campaignSpeechAcervo.e2e.spec.ts`, describe C211 em `:1384+`): communicator apaga peça `publicado` com mídia via HTTP → 200; some da lista; `/conteudos/<slug>` e `/midia` → 404 para anônimo; arquivo privado → 404; `advisor` → 400 com `não tem acesso`; lista/ficha renderizam `Apagar`.
- **Manifest**: nenhum prefixo obrigatório para os arquivos já cobertos (`.../comunicacao`, `src/components/campaign/content`, `src/utilities/content`, `ContentPiece.ts`/`ContentMedia.ts`); **um** prefixo novo para `src/components/campaign/shared/CampaignDeleteDialog` no bloco da vertical (`:463-506`). `src/utilities/access` e `src/lib/schemas` já são `E2E_RISK_PREFIXES` (diff high-risk → conjunto curado).
### Dados → forma (se aplicável)
Não aplicável — a intenção já decide que é decisão de estado do item, não painel (sem contadores de exclusão, sem auditoria exposta, sem dashboard). A única informação nova exibida é o path público `/conteudos/<slug>` no corpo da confirmação (cena E), que já existe e não é leitura agregada.
## Fases verificáveis
1. **Tracer / servidor+access (≈40% do appetite):** `canDeleteContentPiece` + barrel + `ContentPiece.access.delete` + `afterDelete` + schema + `deleteContentPieceForActor` + rota DELETE + tipos + href; int mínimo (communicator apaga peça com mídia e ela some do DB). Prova: `pnpm test:int tests/int/contentPiece.int.spec.ts` verde; `payload.delete` com `overrideAccess:false` negando advisor/leader; `pnpm migrate:status` limpo.
2. **Cascata e cobertura (≈20%):** poll da mídia pós-commit, delete em `processando` + resiliência do worker, contadores preservados, spy do `revalidateTag`, negações. Prova: os mesmos ints; nenhum resíduo de `contentMedia` no caso commitado; job resolvendo sem recriar nada.
3. **UI (≈30%):** `CampaignDeleteDialog` + adapters (C183/C199 refatorados sem mudança de markup/props) + `ContentPieceDeleteDialog` + `publicPath` no row VM/select + wiring nas cenas A–D; unit do diálogo. Prova: `pnpm test:unit tests/unit/contentPieceDeleteDialog.unit.spec.tsx`; specs do C183/C199 verdes sem edição; screenshots 390/1280 dos estados críticos para a crítica (c).
4. **Fechamento (≈10%):** e2e C211 + prefixo do manifesto; `pnpm gate:fast`; `pnpm test:e2e:affected`; crítica do `designer` (c) conforme a regra do §Design (port mecânico do artefato aprovado é non-trigger, mas o fechamento certifica o renderizado); entrada `docs/changelog/2026-09-24-C222-central-de-conteudos-apagar-peca.md`; `/simplify`; `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- **Soft delete / lixeira / `trash` do Payload / restauração** — anti-goal de produto; delete é hard (config sem `trash`).
- **Lote, undo, confirmação em massa, progresso** — v1 é uma peça.
- **Faxina de `ContentEvent`** — `subjectId` texto sem FK; os eventos são inertes e ficam (a intenção proíbe).
- **GC/reconcile de objetos S3 órfãos por rollback do job** — fora; gatilho de revisitação se houver resíduo observável.
- **Lock/advisory entre o job de extração e o delete** — fora; a leitura do `media` no ato do delete cobre o caso commitado e o teste prova o worker.
- **Estender `campaignJsonMutationRoute` para DELETE** — o wrapper é POST-only por contrato e as duas rotas DELETE existentes já pinam o shape manual; não reabrir.
- **Mexer em `contentPieceReads.ts`/`documents.ts` além do hook** — a tag e o helper já são o dono.
- **Relocar `Anexar` (e o Reprocessar do painel de falha) na ficha** — divergência pré-existente do C211; fora.
- **Métricas/telemetria de exclusão, dashboard nova, auditoria exposta** — anti-goals.
- **Migration** — nenhuma.
## Já resolvido no simplify/critique (não reabrir)
Correções aplicadas na sessão — não reabrir em review/follow-up:
- **Título do diálogo sem o `border-b`/`pb-2` do `h2` global:** `ContentPieceDeleteDialog` passa `titleClassName="border-b-0 pb-0"` (mesmo reset do C199).
- **`trigger: ReactElement`** no `CampaignDeleteDialog` — o `asChild` do `AlertDialogTrigger` exige elemento, não qualquer nó React.
- **`slug: true` removido do `contentPieceDetailSelect`** (herdado do spread do select de lista).
- **`prettier --write` nos arquivos do diff;** `prettier --check` limpo (o `format:check` falhava em 3 arquivos).
- **Teste do cache honesto:** `mockClear()` do `revalidateTag` **depois** do create — a asserção pina o `afterDelete`, não o `afterChange` do create.
- **Comentário do `ContentPieceTable` corrigido** ("beside that next action"; sem o antigo "visually below").
- **`CampaignDeleteDialogResponse` volta a tipo interno** (sem consumidor externo).
- **Nome do teste int do not-found** descreve o caminho de fato (`refuses the delete from a denied role, keeps the piece and 404s an unknown id`).
## Explicitamente fora (descartes e defers com gatilho)
- **3ª cópia literal do envelope DELETE** (`conteudos/[id]/apagar/route.ts` repete `cortes`/`gravacoes`: `isSameOriginRequest` → 403, `strictDecimalInteger` → 400, `campaignJsonMutationErrorResponse`) — **defer, gatilho:** uma 4ª rota DELETE em `(campaign)` **ou** drift observado entre as cópias (fix aplicado a uma só). Congelado de propósito em C183/C222 (wrapper POST-only por contrato; o mapper já é compartilhado; o sweep `codebaseConventions.unit.spec.ts:263` pina a guarda de origem em todo `export const DELETE`) — não reabrir antes do gatilho.
- **Testes do `contentPieceDeleteDialog.unit.spec.tsx` que espelham o spec do C183** — descartar: os três testes "de máquina" (endpoint/refresh, redirect, erro sem refresh) pinam a **política do adapter** da peça (URL e `redirectTo` próprios, diferentes do corte) e a máquina compartilhada já está pinada no `speechCutDeleteDialog.unit.spec.tsx`; removê-los perderia cobertura de wiring sem ganho.
- **Peso do "Reprocessar" divergente entre os artefatos C211 (outline) e C222 (primário)** — defer, gatilho: crítica do `designer` (c) desta entrega julgar a divergência material; o C211 é o dono do botão e o implementador manteve o outline. Se material, corrige no próximo item de UI da vertical (follow-up do C211), fora do aceite do C222.
## Riscos e mitigação
- **Peça fantasma na pública** (tag `contentPieces` não bustada) → `afterDelete` no mesmo seam do `afterChange`; int com spy do `revalidateTag` e e2e anônimo 404 em `/conteudos/<slug>` e `/midia`.
- **Mídia órfã (row/S3)** → id lido do doc retornado pelo delete (fecha a corrida com o attach do job) + limpeza pós-commit best-effort (C199); int com poll provando a row de `contentMedia` some; resíduo residual só na janela exata em que o job tem a transação revertida — nomeado, com gatilho de GC.
- **Delete em `processando` derrubar o worker** → o catch externo já engole `findByID`/`failContentPiece` sobre row sumida; int roda `runContentPieceJob` depois do delete e exige resolução + nenhuma mídia nova; se falhar, o plano barato é o guard `findByID(...).catch(() => null)`.
- **Bypass via REST/GraphQL** → access do collection (`canDeleteContentPiece`) é a barreira final; int nega advisor/leader/anônimo e libera os três papéis da vertical + admin.
- **DELETE sem guard de origem falha aberto** → linha `isSameOriginRequest` copiada do shape pinado; sweep `codebaseConventions.unit.spec.ts:263` já cobre `(campaign)`.
- **`revalidateTag` fora de request scope** (fixtures/seeds/e2e) → `revalidateTagSafely` em `documents.ts` já engole só o invariante; demais falhas propagam.
- **`slug` ausente na lista** → `slug: true` no select + `publicPath: null`; o aviso nomeado só aparece publicado com path, senão cena F.
- **Copy/literais** → diálogo com os literais da intenção/artefato; rota com `CONTENT_PIECE_*` (o sweep recusa literal em `safeMessages`).
- **Extração do diálogo regredir C183/C199** → markup/classe idênticos, nomes/props preservados, specs unitários existentes sem edição + e2e afetados (`campaignSpeechAcervo`, `campaignSpeechCut`, `campaignReel`); se a paridade quebrar, fallback B com débito registrado.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: Apagar na lista e na ficha com confirmação e literais; aviso do link `/conteudos/<slug>` só quando publicado; apagar em todos os estados; mídia privada sai junto; `ContentEvent` preservado; `communicator`/`coordinator`/`candidate` + admin apagam, `advisor`/`leader` fail-closed; hard delete sem lote/lixeira/telemetria.
- [ ] Invariantes AGENTS/engineering-standards: Local API com `user` → `overrideAccess:false` na peça; escrita multi-collection (peça + mídia pós-commit) com `withPayloadTransaction` + `req`; bypass só no cleanup justificado como no C199; sem Consent/PII novo; identificadores em inglês, strings pt-BR; `migrate:status` limpo.
- [ ] Testes de domínio previstos onde access/write paths mudam: int (access, cascata, qualquer estado, worker, contadores, cache), unit (diálogo e adapters), e2e (HTTP delete, 404 público, negação).
- [ ] Cache público honesto (`afterDelete`) e design portado classe-a-classe (cenas A–H) com crítica final do `designer` conforme §Design.
- [ ] Manifesto e2e atualizado (prefixo do componente compartilhado) e `pnpm test:e2e:affected` verde antes do `pnpm push`.
## Self-score (decision-quality)
5/5 — (1) as cinco decisões caras têm opções e rejeitadas explícitas, ancoradas em evidência de código (access como barreira final; C199 vs C183 para a cascata; tag sem TTL; sweeps pinados; ciclo de import no VM de wire); (2) cabe no appetite herdado — um predicado, uma action, um hook, uma rota, uma extração de diálogo e wiring, sem migration nem lote/lixeira/GC; (3) rabbit holes nomeados (soft delete, lote, faxina de `ContentEvent`, GC de S3, locks, relocação de `Anexar`, wrapper DELETE); (4) depth check respeitado — reusa `withPayloadTransaction`/`onPayloadTransactionCommit`, `revalidateContentPiecesListing`, `deleteCampaignJson`, `campaignJsonMutationErrorResponse`, `CampaignTable`/cards e extrai a máquina do diálogo no terceiro call site em vez de triplicar; (5) o outcome da intenção permanece intacto — nenhuma decisão alterou aceite, audiência ou literais.

View file

@ -471,6 +471,9 @@ export const E2E_AFFECTED_MANIFEST = [
'src/components/campaign/shared/AcervoSourceToggle',
'src/components/campaign/shared/AcervoSortSelect',
'src/components/campaign/shared/CampaignTranscriptSegmentButton',
// C222 — the shared delete-confirmation machine of the vertical (cut,
// recording and content piece adapters ride the same specs).
'src/components/campaign/shared/CampaignDeleteDialog',
// C219 — the recording collection owns the facet/provenance hooks the
// recordings list filters on (the PR is high-risk anyway; the entry keeps
// the mapping honest for the curated/local paths).

View file

@ -0,0 +1,61 @@
import { NextResponse } from 'next/server'
import { deleteContentPieceForActor } from '@/app/(campaign)/campanha/actions/contentPieces'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
CONTENT_PIECE_NOT_FOUND_MESSAGE,
} from '@/lib/schemas/contentPiece'
import {
CAMPAIGN_JSON_INVALID_REQUEST_MESSAGE,
campaignJsonMutationErrorResponse,
} from '@/utilities/campaignJsonMutationRoute'
import { strictDecimalInteger } from '@/utilities/campaignListUrl'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import type { ContentPieceDeleteResponse } from '../../types'
type RouteContext = {
params: Promise<{ id: string }>
}
export const dynamic = 'force-dynamic'
/**
* C222 — hard deletes one piece (row + its private media file). 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 shape the C183/C199 DELETE routes pin. The
* `codebaseConventions` sweep pins the `isSameOriginRequest` call for every
* DELETE route.
*/
export const DELETE = async (
request: Request,
context: RouteContext,
): Promise<NextResponse<ContentPieceDeleteResponse>> => {
if (!isSameOriginRequest(request)) {
return NextResponse.json(
{ status: 'error', message: CAMPAIGN_JSON_INVALID_REQUEST_MESSAGE },
{ status: 403 },
)
}
const { id } = await context.params
const contentPieceId = strictDecimalInteger(id)
if (!contentPieceId) {
return NextResponse.json(
{ status: 'error', message: CONTENT_PIECE_NOT_FOUND_MESSAGE },
{ status: 400 },
)
}
try {
await deleteContentPieceForActor({ contentPieceId })
return NextResponse.json<ContentPieceDeleteResponse>({ status: 'success', deleted: true })
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: [CONTENT_PIECE_FORBIDDEN_MESSAGE, CONTENT_PIECE_NOT_FOUND_MESSAGE],
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
})
}
}

View file

@ -7,6 +7,7 @@ import { getPayload } from 'payload'
import { searchContentPieceLeaderOptionsForActor } from '@/app/(campaign)/campanha/actions/contentPieces'
import { ContentPieceAttachFileButton } from '@/components/campaign/content/ContentPieceAttachFileButton'
import { ContentPieceCirculationPanel } from '@/components/campaign/content/ContentPieceCirculationPanel'
import { ContentPieceDeleteDialog } from '@/components/campaign/content/ContentPieceDeleteDialog'
import { ContentPieceForm } from '@/components/campaign/content/ContentPieceForm'
import { ContentPiecePublicationButton } from '@/components/campaign/content/ContentPiecePublicationButton'
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
@ -118,7 +119,20 @@ export default async function ContentPieceDetailPage({ params }: ContentPieceDet
<ContentPiecePublicationBadge status={piece.status} />
</div>
</div>
<ContentPiecePublicationButton contentPieceId={piece.id} status={piece.status} />
<div className="flex max-md:w-full shrink-0 flex-wrap items-center justify-end gap-2">
<ContentPiecePublicationButton
contentPieceId={piece.id}
status={piece.status}
className="max-md:w-full"
/>
<ContentPieceDeleteDialog
contentPieceId={piece.id}
status={piece.status}
publicPath={piece.publicPath}
redirectTo={CAMPAIGN_COMMUNICATION_CONTEUDOS}
triggerClassName="max-md:w-full"
/>
</div>
</div>
</div>

View file

@ -29,3 +29,8 @@ export type ContentPiecePublicationResponse =
export type ContentPieceAttachResponse =
| { status: 'success'; piece: ContentPieceViewModel }
| { status: 'error'; message: string }
/** Wire contract of `DELETE .../conteudos/[id]/apagar` (C222) — hard delete. */
export type ContentPieceDeleteResponse =
| { status: 'success'; deleted: true }
| { status: 'error'; message: string }

View file

@ -4,6 +4,7 @@ import type { Payload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import {
CONTENT_MEDIA_SLUG,
contentPieceLinkTitle,
isContentPieceTopic,
parseContentPieceLink,
@ -18,6 +19,7 @@ import {
CONTENT_PIECE_LINK_INVALID_MESSAGE,
CONTENT_PIECE_NOT_FOUND_MESSAGE,
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
contentPieceDeleteRequestSchema,
contentPieceLinkRequestSchema,
contentPiecePublicationRequestSchema,
contentPieceRetryRequestSchema,
@ -35,7 +37,7 @@ import {
type ContentPieceLeaderOption,
} from '@/utilities/content/contentPieceLeaderOptions'
import { startContentPieceJobInBackground } from '@/utilities/content/contentPieceScheduler'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { onPayloadTransactionCommit, withPayloadTransaction } from '@/utilities/payloadTransaction'
/**
* C211 — mutations of the Central de Conteúdos: edit one piece's catalogue,
@ -213,6 +215,58 @@ export const retryContentPieceForActor = async (input: {
return toContentPieceViewModel(piece)
}
/**
* C222 — hard-deletes one piece: the row goes inside the transaction (the
* collection access is the final barrier); the private media row is removed
* after commit, best-effort, so a storage hiccup cannot fail the delete the
* person asked for (same shape as the C199 recording delete). The media id is
* read from the doc the delete returned — reading it in the delete itself
* closes the window in which the pipeline could have attached a file between a
* pre-read and the commit. Deleting a `processando` row is deliberate: it is
* how the assessoria cancels a stuck piece, and the job tolerates the row
* disappearing.
*/
export const deleteContentPieceForActor = async (input: {
contentPieceId: number
}): Promise<{ deleted: true }> => {
const parsed = contentPieceDeleteRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
const current = await loadContentPieceForActor(payload, actor, parsed.contentPieceId)
if (!current) throw new Error(CONTENT_PIECE_NOT_FOUND_MESSAGE)
await withPayloadTransaction(payload, async ({ transactionID, req }) => {
const deleted = await payload.delete({
collection: 'contentPiece',
id: parsed.contentPieceId,
depth: 0,
select: { media: true },
user: actor,
overrideAccess: false,
req,
})
const mediaId = typeof deleted.media === 'number' ? deleted.media : (deleted.media?.id ?? null)
if (mediaId !== null) {
onPayloadTransactionCommit(transactionID, () => {
void payload
.delete({
collection: CONTENT_MEDIA_SLUG,
id: mediaId,
// Intentional admin bypass: cleanup of the file that belonged to
// the piece this actor was authorized to delete.
overrideAccess: true,
})
.catch(() => undefined)
})
}
})
return { deleted: true }
}
/**
* Polls the statuses of the visible pieces; a stale `processando` row is reaped
* to `falhou` on the way (there is no queue to ask), then the page is read

View file

@ -1,5 +1,6 @@
import type {
CollectionAfterChangeHook,
CollectionAfterDeleteHook,
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
CollectionConfig,
@ -33,9 +34,9 @@ import {
import { normalizeContentPiecePublicFigures } from '@/lib/publicFigureCatalog'
import { uniqueRelationshipIds } from '@/lib/relationship'
import { SPEECH_TOPICS } from '@/lib/speechFacets'
import { payloadAdminOnly } from '@/utilities/access/shared'
import {
canCreateContentPiece,
canDeleteContentPiece,
canReadContentPiece,
canSetCampaignSystemField,
canUpdateContentPiece,
@ -92,6 +93,17 @@ const revalidateContentPieceListing: CollectionAfterChangeHook = ({ doc }) => {
return doc
}
/**
* C222 — the other half of the S27 seam: a deleted piece must leave the public
* listing tag too, or the public Central would keep serving a ghost from a
* cache with no TTL. Hook, not a per-caller line, so the admin delete and any
* future path cannot forget it (same reason as the `afterChange` above).
*/
const revalidateContentPieceListingAfterDelete: CollectionAfterDeleteHook = ({ doc }) => {
revalidateContentPiecesListing()
return doc
}
/** Stamps `publishedAt` on every transition into `publicado` (create included). */
const stampContentPiecePublishedAt: CollectionBeforeChangeHook = ({
data,
@ -243,10 +255,10 @@ export const ContentPiece: CollectionConfig = {
create: canCreateContentPiece,
read: canReadContentPiece,
update: canUpdateContentPiece,
// C211 has no delete surface in the Central: only the Payload admin removes
// a row (the S27/support path), so campaign roles never lose a file by
// accident.
delete: payloadAdminOnly,
// C211 kept no delete surface in the Central; C222 reopens that anti-goal
// on purpose: the same vertical that writes publishes can now remove a
// piece for good, warned by the confirmation dialog. The admin keeps it.
delete: canDeleteContentPiece,
},
hooks: {
beforeValidate: [deriveContentPieceCatalogIndex],
@ -256,6 +268,7 @@ export const ContentPiece: CollectionConfig = {
stampCampaignCreatedBy,
],
afterChange: [revalidateContentPieceListing],
afterDelete: [revalidateContentPieceListingAfterDelete],
},
fields: [
{

View file

@ -1,6 +1,7 @@
import Link from 'next/link'
import { ContentPieceCirculationCounters } from '@/components/campaign/content/ContentPieceCirculationCounters'
import { ContentPieceDeleteDialog } from '@/components/campaign/content/ContentPieceDeleteDialog'
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
import {
ContentPieceProcessingBadge,
@ -16,7 +17,8 @@ import type { ContentPieceRowViewModel } from '@/lib/contentPieceCirculation'
* the single "Abrir" (or "Reprocessar" when it failed). A link piece also
* carries the neutral "Peça-link" label — the honest reason lives on the ficha,
* never on the list (C220 D7). C213 adds the piece's circulation counters (2×2
* grid) right above the action.
* grid) right above the action. C222 adds "Apagar" beside the routine action
* (below it when the piece can be reprocessed), per the approved scene B.
*/
export const ContentPieceCardList = ({
rows,
@ -73,11 +75,26 @@ export const ContentPieceCardList = ({
/>
{piece.canRetry ? (
<ContentPieceRetryButton contentPieceId={piece.id} className="mt-3" />
<>
<ContentPieceRetryButton contentPieceId={piece.id} className="mt-3" />
<ContentPieceDeleteDialog
contentPieceId={piece.id}
status={piece.status}
publicPath={piece.publicPath}
triggerClassName="mt-2 w-full"
/>
</>
) : (
<Button asChild variant="outline" className="mt-3 min-h-11 w-full">
<Link href={piece.detailHref}>Abrir</Link>
</Button>
<div className="mt-3 grid grid-cols-[1fr_auto] gap-2">
<Button asChild variant="outline" className="min-h-11">
<Link href={piece.detailHref}>Abrir</Link>
</Button>
<ContentPieceDeleteDialog
contentPieceId={piece.id}
status={piece.status}
publicPath={piece.publicPath}
/>
</div>
)}
</article>
)

View file

@ -0,0 +1,64 @@
'use client'
import { Trash2Icon } from 'lucide-react'
import { CampaignDeleteDialog } from '@/components/campaign/shared/CampaignDeleteDialog'
import { Button } from '@/components/ui/button'
import { campaignContentPieceDeleteHref } from '@/lib/campaignPaths'
import type { ContentPieceStatus } from '@/lib/contentPiece'
import { cn } from '@/lib/utils'
const DELETE_ERROR_MESSAGE = 'Não foi possível apagar a peça.'
/**
* C222 — the delete confirmation of a content piece, used on the list (desktop
* row and mobile card) and on the ficha. The warning names the public
* `/conteudos/<slug>` only when it will actually break (published piece with a
* slug); every other state keeps the irreversible-only copy. The machine lives
* in `CampaignDeleteDialog`; here is the piece's policy only.
*/
export const ContentPieceDeleteDialog = ({
contentPieceId,
status,
publicPath,
triggerClassName,
redirectTo,
}: {
contentPieceId: number
status: ContentPieceStatus
/** The public path of the piece; null when it never got a slug. */
publicPath: string | null
/** Layout of the trigger (list right-aligns; mobile card/detail goes full width). */
triggerClassName?: string
/** Ficha: the row is gone after the delete, so leave the page. */
redirectTo?: string
}) => (
<CampaignDeleteDialog
endpoint={campaignContentPieceDeleteHref(contentPieceId)}
errorMessage={DELETE_ERROR_MESSAGE}
title="Apagar esta peça?"
titleClassName="border-b-0 pb-0"
description={
status === 'publicado' && publicPath ? (
<>
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.'
)
}
confirmLabel="Apagar"
redirectTo={redirectTo}
trigger={
<Button
type="button"
variant="ghost"
className={cn('min-h-11 text-destructive hover:bg-red-50', triggerClassName)}
>
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
Apagar
</Button>
}
/>
)

View file

@ -2,6 +2,7 @@ import { FilmIcon, ImageIcon, LinkIcon, MicIcon, TypeIcon } from 'lucide-react'
import Link from 'next/link'
import { ContentPieceCirculationCounters } from '@/components/campaign/content/ContentPieceCirculationCounters'
import { ContentPieceDeleteDialog } from '@/components/campaign/content/ContentPieceDeleteDialog'
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
import {
ContentPieceProcessingBadge,
@ -34,6 +35,8 @@ const metaLine = (piece: ContentPieceViewModel): string =>
* C211 — one piece of the Central list (approved design scene 1): thumbnail,
* title and the type/city/duration line, the honest processing state, the
* publication state and one action ("Abrir" or "Reprocessar" when it failed).
* C222 adds the irreversible "Apagar" beside that next action (ghost
* destructive), on every state.
*/
export const ContentPieceTable = ({
rows,
@ -46,8 +49,10 @@ export const ContentPieceTable = ({
}) => (
// C213 (design critique): from xl up the five columns share the width by
// fixed percentages, so the mandatory "Próxima ação" is never pushed out of
// frame by the new "Circulação" column. Below xl the table keeps its natural
// layout and the shell's horizontal scroll (unchanged behavior).
// frame. C222 rebalanced them to the approved design scene A (23/14/12/25/26)
// so the action column fits "Abrir|Reprocessar" plus the delete trigger.
// Below xl the table keeps its natural layout and the shell's horizontal
// scroll (unchanged behavior).
<CampaignTable
className="hidden md:block xl:[&_table]:table-fixed"
caption="Uma linha por peça da Central de Conteúdos. Rascunhos não aparecem na Central pública."
@ -60,7 +65,7 @@ export const ContentPieceTable = ({
id: 'piece',
label: 'Peça',
mandatory: true,
head: <CampaignTableHead className="xl:w-[25%]">Peça</CampaignTableHead>,
head: <CampaignTableHead className="xl:w-[23%]">Peça</CampaignTableHead>,
cellClassName: 'xl:whitespace-normal',
cell: (piece) => {
const Icon = TYPE_ICON[piece.type]
@ -88,7 +93,7 @@ export const ContentPieceTable = ({
{
id: 'processing',
label: 'Processamento',
head: <CampaignTableHead className="xl:w-[13%]">Processamento</CampaignTableHead>,
head: <CampaignTableHead className="xl:w-[14%]">Processamento</CampaignTableHead>,
cell: (piece) => (
<div>
<ContentPieceProcessingBadge status={piece.processingStatus} />
@ -105,13 +110,13 @@ export const ContentPieceTable = ({
{
id: 'publication',
label: 'Publicação',
head: <CampaignTableHead className="xl:w-[11%]">Publicação</CampaignTableHead>,
head: <CampaignTableHead className="xl:w-[12%]">Publicação</CampaignTableHead>,
cell: (piece) => <ContentPiecePublicationBadge status={piece.status} />,
},
{
id: 'circulation',
label: 'Circulação',
head: <CampaignTableHead className="xl:w-[39%]">Circulação</CampaignTableHead>,
head: <CampaignTableHead className="xl:w-[25%]">Circulação</CampaignTableHead>,
cell: (piece) => (
<ContentPieceCirculationCounters
circulation={piece.circulation}
@ -125,23 +130,31 @@ export const ContentPieceTable = ({
label: 'Próxima ação',
mandatory: true,
head: (
<CampaignTableHead align="right" className="xl:w-[12%]">
<CampaignTableHead align="right" className="xl:w-[26%]">
Próxima ação
</CampaignTableHead>
),
cellClassName: 'text-right',
cell: (piece) =>
piece.canRetry ? (
<ContentPieceRetryButton
cell: (piece) => (
<div className="flex items-center justify-end gap-2">
{piece.canRetry ? (
<ContentPieceRetryButton
contentPieceId={piece.id}
label="Reprocessar"
className="inline-block"
/>
) : (
<Button asChild variant="outline" className="min-h-11">
<Link href={piece.detailHref}>Abrir</Link>
</Button>
)}
<ContentPieceDeleteDialog
contentPieceId={piece.id}
label="Reprocessar"
className="inline-block"
status={piece.status}
publicPath={piece.publicPath}
/>
) : (
<Button asChild variant="outline" className="min-h-11">
<Link href={piece.detailHref}>Abrir</Link>
</Button>
),
</div>
),
},
]}
/>

View file

@ -1,24 +1,9 @@
'use client'
import { CircleAlertIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import type { RecordingDeleteResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/types'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/AlertDialog'
import { CampaignDeleteDialog } from '@/components/campaign/shared/CampaignDeleteDialog'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { deleteCampaignJson } from '@/lib/campaignJsonRequest'
import { campaignRecordingDeleteHref } from '@/lib/campaignPaths'
import { cn } from '@/lib/utils'
@ -27,7 +12,8 @@ const DELETE_ERROR_MESSAGE = 'Não foi possível apagar a gravação.'
/**
* C199 — the delete confirmation of the recording detail. The trigger lives
* only here (never on the list cards): an irreversible action stays out of the
* scan path, per the approved design.
* scan path, per the approved design. The machine lives in
* `CampaignDeleteDialog`; here is the recording's policy only.
*/
export const RecordingDeleteDialog = ({
recordingId,
@ -38,80 +24,25 @@ export const RecordingDeleteDialog = ({
/** Detail page: the row is gone after the delete, so leave the page. */
redirectTo?: string
triggerClassName?: string
}) => {
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<RecordingDeleteResponse>(
campaignRecordingDeleteHref(recordingId),
)
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)
}) => (
<CampaignDeleteDialog
endpoint={campaignRecordingDeleteHref(recordingId)}
errorMessage={DELETE_ERROR_MESSAGE}
title="Apagar esta gravação?"
titleClassName="border-b-0 pb-0"
description="O arquivo e toda a transcrição serão removidos. Esta ação não pode ser desfeita."
confirmLabel="Apagar gravação"
footerClassName="border-t-0 bg-transparent"
redirectTo={redirectTo}
trigger={
<Button
type="button"
variant="ghost"
className={cn('min-h-11 text-destructive hover:bg-red-50', triggerClassName)}
>
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
Apagar
</Button>
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
className={cn('min-h-11 text-destructive hover:bg-red-50', triggerClassName)}
>
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
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">
{/* The frontend prose base styles every `h2` with a bottom border. */}
<AlertDialogTitle className="border-b-0 pb-0">Apagar esta gravação?</AlertDialogTitle>
<AlertDialogDescription className="mt-2">
O arquivo e toda a transcrição serão removidos. Esta ação não pode ser desfeita.
</AlertDialogDescription>
</div>
</div>
</AlertDialogHeader>
<AlertDialogFooter className="border-t-0 bg-transparent">
<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 gravação
</Button>
</AlertDialogFooter>
{error ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
</AlertDialogContent>
</AlertDialog>
)
}
/>
)

View file

@ -0,0 +1,127 @@
'use client'
import { CircleAlertIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState, type ReactElement, type ReactNode } from 'react'
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'
/**
* The `{ status }` envelope every `/campanha` delete route answers: the shared
* machine only needs the discriminator and the domain message.
*/
type CampaignDeleteDialogResponse = { status: 'success' } | { status: 'error'; message: string }
type CampaignDeleteDialogProps = {
/** The DELETE endpoint of the row (the id lives in the path). */
endpoint: string
/** Fallback copy for a transport/unknown failure; domain errors travel from the route. */
errorMessage: string
/** The styled trigger button of the caller (the machine owns the confirm flow). */
trigger: ReactElement
title: string
description: ReactNode
confirmLabel: string
/** Detail page: the row is gone after the delete, so leave the page. */
redirectTo?: string
/** Callers riding the frontend prose base reset the title's border. */
titleClassName?: string
/** Callers whose footer chrome differs from the default pass their own. */
footerClassName?: string
}
/**
* The one delete-confirmation machine of the campaign vertical (C183 built it
* for the cut, C199 reused it for the recording, C222 is the third call site —
* extracted here instead of copied again). Owns the submitting/error state, the
* DELETE call, the redirect/refresh decision and the dialog chrome; each caller
* passes policy only (endpoint, copy, trigger, classes). The error always stays
* in the dialog context, never a toast.
*/
export const CampaignDeleteDialog = ({
endpoint,
errorMessage,
trigger,
title,
description,
confirmLabel,
redirectTo,
titleClassName,
footerClassName,
}: CampaignDeleteDialogProps) => {
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<CampaignDeleteDialogResponse>(endpoint)
if (!ok || payload.status !== 'success') {
setError(payload.status === 'error' ? payload.message : errorMessage)
return
}
if (redirectTo) router.push(redirectTo)
else router.refresh()
} catch {
setError(errorMessage)
} finally {
setSubmitting(false)
}
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>{trigger}</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 className={titleClassName}>{title}</AlertDialogTitle>
<AlertDialogDescription className="mt-2">{description}</AlertDialogDescription>
</div>
</div>
</AlertDialogHeader>
{error ? (
<Alert variant="destructive" className="py-2">
<CircleAlertIcon aria-hidden="true" />
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
<AlertDialogFooter className={footerClassName}>
<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}
{confirmLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -1,24 +1,7 @@
'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 { CampaignDeleteDialog } from '@/components/campaign/shared/CampaignDeleteDialog'
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'
@ -38,7 +21,8 @@ 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.
* stops working for whoever already received it. The machine lives in
* `CampaignDeleteDialog`; here is the cut's policy only.
*/
export const SpeechCutDeleteDialog = ({
cutId,
@ -46,87 +30,31 @@ export const SpeechCutDeleteDialog = ({
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),
}: SpeechCutDeleteDialogProps) => (
<CampaignDeleteDialog
endpoint={campaignSpeechCutDeleteHref(cutId)}
errorMessage={DELETE_ERROR_MESSAGE}
title="Apagar este corte?"
description={
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.'
)
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>
)
}
confirmLabel="Apagar"
redirectTo={redirectTo}
trigger={
<Button
type="button"
variant="ghost"
className={cn('text-destructive hover:bg-red-50', triggerClassName)}
>
Apagar
</Button>
}
/>
)

View file

@ -91,6 +91,10 @@ export const campaignContentPieceRetryHref = (id: number): string =>
export const campaignContentPiecePublicationHref = (id: number): string =>
`${campaignContentPieceDetailHref(id)}/publicacao`
/** C222 — JSON endpoint the list and the ficha call to delete a piece for good. */
export const campaignContentPieceDeleteHref = (id: number): string =>
`${campaignContentPieceDetailHref(id)}/apagar`
const campaignRecordingDetailHref = (id: number): string =>
`${CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES}/${id}`

View file

@ -501,6 +501,8 @@ export type ContentPieceViewModel = {
type ContentPieceViewRecord = {
id: number
title?: string | null
/** The public slug (C222): only a once-published piece has one. */
slug?: string | null
type?: string | null
status?: string | null
processingStatus?: string | null

View file

@ -63,6 +63,12 @@ export type ContentPieceCirculationView =
export type ContentPieceRowViewModel = ContentPieceViewModel & {
circulation: ContentPieceCirculationView
/**
* C222 — the public path of the piece (`/conteudos/<slug>`), or null when it
* never got a slug. Computed by the page-data loader from the row's `slug`;
* the delete confirmation names it only when the piece is published.
*/
publicPath: string | null
}
/**

View file

@ -109,6 +109,11 @@ export const contentPieceRetryRequestSchema = z.object({
contentPieceId: z.number().int().positive(),
})
/** Delete one piece for good (row + its private media file). */
export const contentPieceDeleteRequestSchema = z.object({
contentPieceId: z.number().int().positive(),
})
/** Poll the statuses of the visible pieces (bounded batch). */
export const contentPieceStatusRequestSchema = z.object({
contentPieceIds: z.array(z.number().int().positive()).min(1).max(50),

View file

@ -43,3 +43,16 @@ export const canUpdateContentPiece: Access = async ({ req }) => {
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}
/**
* Who may delete a piece for good (C222). The same vertical gate plus the
* Payload admin — and its own predicate, never an alias of the update one: a
* future widening of the edit surface must not silently grant an irreversible
* delete. The confirmation dialog is the brake, not a narrower role.
*/
export const canDeleteContentPiece: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}

View file

@ -187,6 +187,7 @@ export {
export {
canCreateContentPiece,
canDeleteContentPiece,
canReadContentPiece,
canUpdateContentPiece,
} from '@/utilities/access/contentPieces'

View file

@ -3,6 +3,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { toContentPieceViewModel } from '@/lib/contentPiece'
import { contentPiecePublicPath } from '@/lib/contentPieceCatalog'
import {
contentPieceCirculationFromRows,
resolveContentPieceCirculation,
@ -33,6 +34,9 @@ import { createEntityNotFoundError } from '@/utilities/entityNotFound'
*/
const contentPieceListSelect = {
title: true,
// C222 — the delete confirmation names the public link; only a piece that was
// ever published has a slug.
slug: true,
type: true,
status: true,
processingStatus: true,
@ -51,7 +55,6 @@ const contentPieceListSelect = {
/** Everything the ficha renders, including the editable catalogue. */
const contentPieceDetailSelect = {
...contentPieceListSelect,
slug: true,
description: true,
transcript: true,
institution: true,
@ -117,6 +120,9 @@ const toContentPieceRow = (
lookup: ContentPieceCirculationLookup,
): ContentPieceRowViewModel => ({
...toContentPieceViewModel(record),
// C222 — the row carries the public path the delete dialog names; a piece
// that never got a slug (never published) resolves to null.
publicPath: record.slug ? contentPiecePublicPath(record.slug) : null,
circulation: resolveContentPieceCirculation({
counts: lookup.ok ? lookup.byPieceId.get(record.id) : undefined,
hasBeenPublished: Boolean(record.publishedAt),

View file

@ -1780,5 +1780,82 @@ test.describe('communication vertical (C154/C162)', () => {
expect(list).toContain('Peça-link')
expect(list).not.toContain('Carrossel: sem arquivo único para baixar')
})
test('deletes a piece over HTTP (public link and private file die) and denies the non-vertical roles (C222)', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('pecaapagar')
const { piece, media } = await createPiece(campaign, { marker, status: 'publicado' })
const slug = piece.slug!
const title = `Peça ${marker}`
const communicator = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(communicator, communicator.password)
// The list and the ficha offer the irreversible action on a published piece.
const list = rendered(
await (await request.get(`/campanha/comunicacao/conteudos?q=${marker}`)).text(),
)
expect(list).toContain(title)
expect(list).toContain('Apagar')
const ficha = rendered(
await (await request.get(`/campanha/comunicacao/conteudos/${piece.id}`)).text(),
)
expect(ficha).toContain('Apagar')
const publicBefore = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
try {
expect((await publicBefore.get(`/conteudos/${slug}`)).status()).toBe(200)
expect((await publicBefore.get(`/conteudos/${slug}/midia`)).status()).toBe(200)
} finally {
await publicBefore.dispose()
}
// Fail-closed: an advisor cannot delete the piece through the route.
const advisor = await campaign.fixtures.createCampaignUser('advisor')
const deniedRequest = await campaignRequest(advisor, advisor.password)
const denied = await deniedRequest.delete(
`/campanha/comunicacao/conteudos/${piece.id}/apagar`,
)
expect(denied.status()).toBe(400)
expect(((await denied.json()) as { message: string }).message).toContain(
'não tem acesso à Central de Conteúdos',
)
const deleted = await request.delete(`/campanha/comunicacao/conteudos/${piece.id}/apagar`)
expect(deleted.status()).toBe(200)
expect(await deleted.json()).toEqual({ status: 'success', deleted: true })
const afterList = rendered(
await (await request.get(`/campanha/comunicacao/conteudos?q=${marker}`)).text(),
)
expect(afterList).not.toContain(title)
// The authenticated private file is gone too (the row no longer exists).
expect(
(await request.get(`/campanha/comunicacao/conteudos/${piece.id}/arquivo`)).status(),
).toBe(404)
const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
try {
expect((await anonymous.get(`/conteudos/${slug}`)).status()).toBe(404)
expect((await anonymous.get(`/conteudos/${slug}/midia`)).status()).toBe(404)
} finally {
await anonymous.dispose()
}
// The row is really gone, media included (the cleanup runs after commit).
await expect
.poll(
async () =>
campaign.fixtures.payload
.findByID({ collection: 'contentMedia', id: media!.id, depth: 0 })
.then(() => false)
.catch(() => true),
{ timeout: 10_000 },
)
.toBe(true)
})
})
})

View file

@ -5,13 +5,14 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { revalidateTag } from 'next/cache'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: () => undefined,
revalidateTag: vi.fn(),
unstable_cache: (fn: unknown) => fn,
}))
@ -31,6 +32,7 @@ vi.mock('@/utilities/campaignAuth', () => ({
import { GET } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/arquivo/route'
import {
addContentPieceByLinkForActor,
deleteContentPieceForActor,
getContentPieceStatusesForActor,
retryContentPieceForActor,
searchContentPieceLeaderOptionsForActor,
@ -47,12 +49,13 @@ import {
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
CONTENT_PIECE_NOT_FOUND_MESSAGE,
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
} from '@/lib/schemas/contentPiece'
import { slugify } from '@/lib/slug'
import type { SpeechTopic } from '@/lib/speechFacets'
import { normalizeForSearch } from '@/lib/speechSearch'
import type { CampaignUser, ContentMedia } from '@/payload-types'
import type { CampaignUser, ContentMedia, User } from '@/payload-types'
import config from '@/payload.config'
import {
CONTENT_PIECE_STALE_MS,
@ -76,6 +79,7 @@ import {
attachContentPieceMedia,
receiveContentPieceUpload,
} from '@/utilities/content/contentPieceUpload'
import { getCollectionListingTag } from '@/utilities/documents'
import type { InstagramPost } from '@/utilities/socialFeed/instagramFeed'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
@ -587,6 +591,171 @@ describe('content pieces (C211)', () => {
)
})
describe('delete a piece (C222)', () => {
const mediaGone = async (mediaId: number): Promise<boolean> => {
for (let attempt = 0; attempt < 20; attempt += 1) {
const gone = await payload
.findByID({ collection: 'contentMedia', id: mediaId, overrideAccess: true })
.then(() => false)
.catch(() => true)
if (gone) return true
await new Promise((resolve) => setTimeout(resolve, 50))
}
return false
}
it('deletes a piece on the collection only with the communication roles and the admin', async () => {
const admin = await campaignFixtures().createAdminUser()
const deleteAs = async (user?: CampaignUser | User) => {
const { piece } = await createPiece({ title: `Acesso ${Date.now()}` })
const deleted = await payload
.delete({
collection: 'contentPiece',
id: piece.id,
...(user ? { user } : {}),
overrideAccess: false,
})
.then(() => true)
.catch(() => false)
if (deleted) createdPieceIds.delete(piece.id)
return deleted
}
// Fail-closed: anonymous, advisor and leader are denied.
expect(await deleteAs()).toBe(false)
expect(await deleteAs(advisor)).toBe(false)
expect(await deleteAs(leader)).toBe(false)
// The vertical that writes/publicizes removes; the admin keeps the path.
expect(await deleteAs(communicator)).toBe(true)
expect(await deleteAs(coordinator)).toBe(true)
expect(await deleteAs(candidate)).toBe(true)
expect(await deleteAs(admin)).toBe(true)
})
it('hard deletes the piece with its private media and leaves the circulation events', async () => {
const marker = Date.now().toString(36)
const { piece, media } = await createPiece({
title: `Peça para apagar ${marker}`,
status: 'publicado',
})
const event = await payload.create({
collection: 'contentEvent',
data: { type: 'abertura', subjectType: 'peca', subjectId: String(piece.id) },
overrideAccess: true,
})
const publicSlug = piece.slug!
expect(await getPublishedContentPieceBySlug(publicSlug)).not.toBeNull()
getCampaignUserMock.mockResolvedValue(communicator)
await expect(deleteContentPieceForActor({ contentPieceId: piece.id })).resolves.toEqual({
deleted: true,
})
createdPieceIds.delete(piece.id)
await expect(
payload.findByID({ collection: 'contentPiece', id: piece.id, overrideAccess: true }),
).rejects.toThrow()
expect(await getPublishedContentPieceBySlug(publicSlug)).toBeNull()
if (media) {
// The media cleanup runs after commit, best-effort; poll for it.
expect(await mediaGone(media.id)).toBe(true)
createdMediaIds.delete(media.id)
}
// C213 contract: the anonymous counters are inert history, never cascaded.
const survivor = await payload.findByID({
collection: 'contentEvent',
id: event.id,
overrideAccess: true,
})
expect(survivor.subjectId).toBe(String(piece.id))
await payload.delete({ collection: 'contentEvent', id: event.id, overrideAccess: true })
})
it('refuses the delete from a denied role, keeps the piece and 404s an unknown id', async () => {
const { piece } = await createPiece()
getCampaignUserMock.mockResolvedValue(advisor)
await expect(deleteContentPieceForActor({ contentPieceId: piece.id })).rejects.toThrow(
CONTENT_PIECE_FORBIDDEN_MESSAGE,
)
expect(
await payload.findByID({ collection: 'contentPiece', id: piece.id, overrideAccess: true }),
).toMatchObject({ id: piece.id })
getCampaignUserMock.mockResolvedValue(communicator)
await expect(
deleteContentPieceForActor({ contentPieceId: piece.id + 100_000 }),
).rejects.toThrow(CONTENT_PIECE_NOT_FOUND_MESSAGE)
})
it('deletes a piece in any state and never trips the running worker', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
for (const [status, processingStatus] of [
['rascunho', 'pronto'],
['publicado', 'pronto'],
['rascunho', 'falhou'],
['rascunho', 'processando'],
] as const) {
const { piece } = await createPiece({ status, processingStatus })
await deleteContentPieceForActor({ contentPieceId: piece.id })
createdPieceIds.delete(piece.id)
await expect(
payload.findByID({ collection: 'contentPiece', id: piece.id, overrideAccess: true }),
).rejects.toThrow()
}
// A job still scheduled for a deleted `processando` row finds nothing and
// resolves: the row is gone, no failure and no media is recreated.
const { piece } = await createPiece({ processingStatus: 'processando' })
await deleteContentPieceForActor({ contentPieceId: piece.id })
createdPieceIds.delete(piece.id)
await expect(runContentPieceJob(payload, piece.id)).resolves.toBeUndefined()
const resurrected = await payload.find({
collection: 'contentPiece',
where: { id: { equals: piece.id } },
limit: 0,
pagination: false,
overrideAccess: true,
})
// The worker cannot attach media to a row that no longer exists.
expect(resurrected.docs).toHaveLength(0)
})
it('busts the public listing tag when a piece is deleted', async () => {
const tag = getCollectionListingTag('contentPiece')
const { piece } = await createPiece({ title: `Cache ${Date.now()}` })
// The create's own afterChange also busts the tag; clear it AFTER the
// create so the assertion pins the afterDelete hook alone.
vi.mocked(revalidateTag).mockClear()
await payload.delete({ collection: 'contentPiece', id: piece.id, overrideAccess: true })
createdPieceIds.delete(piece.id)
expect(revalidateTag).toHaveBeenCalledWith(tag)
})
it('carries the public path on the row for the delete warning', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const publishedPiece = await createPiece({ status: 'publicado', title: `VM ${Date.now()}` })
const draftPiece = await createPiece({
status: 'rascunho',
title: `VM rascunho ${Date.now()}`,
})
const data = await loadContentPieceListPageData(payload, communicator, {})
const publishedRow = data.rows.find((row) => row.id === publishedPiece.piece.id)
const draftRow = data.rows.find((row) => row.id === draftPiece.piece.id)
expect(publishedRow?.publicPath).toBe(`/conteudos/${publishedPiece.piece.slug}`)
expect(draftRow?.publicPath).toBeNull()
})
})
it('reaps a stale processing row to failed and reports the repaired status', async () => {
const { piece } = await createPiece({ processingStatus: 'processando' })
await payload.update({

View file

@ -0,0 +1,116 @@
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 { ContentPieceDeleteDialog } from '@/components/campaign/content/ContentPieceDeleteDialog'
const fetchMock = vi.fn()
const jsonResponse = (payload: unknown, ok = true) => ({ ok, json: async () => payload })
const renderDialog = (overrides: Partial<Parameters<typeof ContentPieceDeleteDialog>[0]> = {}) => {
render(
<ContentPieceDeleteDialog
contentPieceId={321}
status="publicado"
publicPath="/conteudos/fim-da-escala-6x1"
{...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('ContentPieceDeleteDialog (C222)', () => {
it('names the public link only when the published piece has a slug', async () => {
renderDialog({ status: 'publicado', publicPath: '/conteudos/fim-da-escala-6x1' })
const dialog = await openConfirm()
expect(within(dialog).getByText('Apagar esta peça?')).toBeDefined()
expect(within(dialog).getByText('/conteudos/fim-da-escala-6x1')).toBeDefined()
expect(within(dialog).getByText(/deixa de funcionar para quem já recebeu/)).toBeDefined()
})
it('keeps only the irreversible warning on a draft', async () => {
renderDialog({ status: 'rascunho', publicPath: null })
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('does not name a link when the piece has no public path', async () => {
renderDialog({ status: 'publicado', publicPath: null })
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/conteudos/321/apagar',
expect.objectContaining({ method: 'DELETE' }),
)
})
it('redirects to the list after delete when asked (ficha)', async () => {
fetchMock.mockResolvedValue(jsonResponse({ status: 'success', deleted: true }))
renderDialog({ redirectTo: '/campanha/comunicacao/conteudos' })
await openConfirm()
confirm()
await waitFor(() =>
expect(routerMock.push).toHaveBeenCalledWith('/campanha/comunicacao/conteudos'),
)
expect(routerMock.refresh).not.toHaveBeenCalled()
})
it('surfaces the domain error and does not refresh', async () => {
fetchMock.mockResolvedValue(
jsonResponse({ status: 'error', message: 'Peça não encontrada.' }, false),
)
renderDialog()
await openConfirm()
confirm()
expect(await screen.findByText('Peça não encontrada.')).toBeDefined()
expect(routerMock.refresh).not.toHaveBeenCalled()
})
})