From 63bd370b6cb403fa24dc5a73a8ec9c761a5bb803 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 23:25:43 -0300 Subject: [PATCH] =?UTF-8?q?feat(C222):=20apagar=20uma=20pe=C3=A7a=20na=20C?= =?UTF-8?q?entral=20de=20Conte=C3=BAdos=20(Closes=20#1330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ...4-C222-central-de-conteudos-apagar-peca.md | 1 + .../central-conteudos-apagar-peca-impl.md | 185 ++++++++++++++++++ scripts/lib/e2e-affected-manifest.mjs | 3 + .../conteudos/[id]/apagar/route.ts | 61 ++++++ .../(app)/comunicacao/conteudos/[id]/page.tsx | 16 +- .../(app)/comunicacao/conteudos/types.ts | 5 + .../campanha/actions/contentPieces.ts | 56 +++++- src/collections/ContentPiece.ts | 23 ++- .../campaign/content/ContentPieceCardList.tsx | 27 ++- .../content/ContentPieceDeleteDialog.tsx | 64 ++++++ .../campaign/content/ContentPieceTable.tsx | 47 +++-- .../recording/RecordingDeleteDialog.tsx | 117 +++-------- .../campaign/shared/CampaignDeleteDialog.tsx | 127 ++++++++++++ .../campaign/speech/SpeechCutDeleteDialog.tsx | 130 +++--------- src/lib/campaignPaths.ts | 4 + src/lib/contentPiece.ts | 2 + src/lib/contentPieceCirculation.ts | 6 + src/lib/schemas/contentPiece.ts | 5 + src/utilities/access/contentPieces.ts | 13 ++ src/utilities/campaignAccess.ts | 1 + src/utilities/content/contentPiecePageData.ts | 8 +- tests/e2e/campaignSpeechAcervo.e2e.spec.ts | 77 ++++++++ tests/int/contentPiece.int.spec.ts | 173 +++++++++++++++- .../contentPieceDeleteDialog.unit.spec.tsx | 116 +++++++++++ 24 files changed, 1041 insertions(+), 226 deletions(-) create mode 100644 docs/changelog/2026-09-24-C222-central-de-conteudos-apagar-peca.md create mode 100644 docs/plans/central-conteudos-apagar-peca-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/apagar/route.ts create mode 100644 src/components/campaign/content/ContentPieceDeleteDialog.tsx create mode 100644 src/components/campaign/shared/CampaignDeleteDialog.tsx create mode 100644 tests/unit/contentPieceDeleteDialog.unit.spec.tsx diff --git a/docs/changelog/2026-09-24-C222-central-de-conteudos-apagar-peca.md b/docs/changelog/2026-09-24-C222-central-de-conteudos-apagar-peca.md new file mode 100644 index 00000000..232b8a54 --- /dev/null +++ b/docs/changelog/2026-09-24-C222-central-de-conteudos-apagar-peca.md @@ -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/ 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/` 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/` 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`. diff --git a/docs/plans/central-conteudos-apagar-peca-impl.md b/docs/plans/central-conteudos-apagar-peca-impl.md new file mode 100644 index 00000000..f22d6862 --- /dev/null +++ b/docs/plans/central-conteudos-apagar-peca-impl.md @@ -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/` 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
Abrir | Reprocessar + Apagar (todos os estados)"] + F["Ficha [id]: fila de ações do topo
Publicar/Despublicar + Apagar"] + end + L --> Dlg + F --> Dlg + Dlg["ContentPieceDeleteDialog (cenas E–H)
AlertDialog + deleteCampaignJson"] -->|"DELETE .../conteudos/:id/apagar"| R + R["route.ts novo
isSameOriginRequest → strictDecimalInteger"] --> A + A["deleteContentPieceForActor
gate communicationCatalog + not found"] --> Tx + Tx["withPayloadTransaction
payload.delete(contentPiece, select media)
access canDeleteContentPiece"] -->|commit| M + Tx -->|afterDelete| C + M["onPayloadTransactionCommit
delete contentMedia, overrideAccess true, best-effort"] --> S3["objeto privado sai do storage"] + C["revalidateContentPiecesListing()
tag contentPieces"] --> Pub["/conteudos e /conteudos/slug/midia
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/` 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 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/` 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/` 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/` 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/` 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. diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 91df6e25..ccd71abf 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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). diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/apagar/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/apagar/route.ts new file mode 100644 index 00000000..2ff6fe7f --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/apagar/route.ts @@ -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> => { + 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({ 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, + }) + } +} diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/page.tsx index d237cc55..42cbea20 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/page.tsx @@ -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 - +
+ + +
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/types.ts b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/types.ts index d698dacf..08c4a924 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/types.ts +++ b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/types.ts @@ -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 } diff --git a/src/app/(campaign)/campanha/actions/contentPieces.ts b/src/app/(campaign)/campanha/actions/contentPieces.ts index 29b0e62b..31ffeef4 100644 --- a/src/app/(campaign)/campanha/actions/contentPieces.ts +++ b/src/app/(campaign)/campanha/actions/contentPieces.ts @@ -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 diff --git a/src/collections/ContentPiece.ts b/src/collections/ContentPiece.ts index 3f9a8337..9a39e887 100644 --- a/src/collections/ContentPiece.ts +++ b/src/collections/ContentPiece.ts @@ -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: [ { diff --git a/src/components/campaign/content/ContentPieceCardList.tsx b/src/components/campaign/content/ContentPieceCardList.tsx index a4f7c357..dafc0445 100644 --- a/src/components/campaign/content/ContentPieceCardList.tsx +++ b/src/components/campaign/content/ContentPieceCardList.tsx @@ -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 ? ( - + <> + + + ) : ( - +
+ + +
)} ) diff --git a/src/components/campaign/content/ContentPieceDeleteDialog.tsx b/src/components/campaign/content/ContentPieceDeleteDialog.tsx new file mode 100644 index 00000000..36d8aa07 --- /dev/null +++ b/src/components/campaign/content/ContentPieceDeleteDialog.tsx @@ -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/` 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 +}) => ( + + O link público {publicPath} deixa de + funcionar para quem já recebeu. Esta ação não pode ser desfeita. + + ) : ( + 'Esta ação não pode ser desfeita.' + ) + } + confirmLabel="Apagar" + redirectTo={redirectTo} + trigger={ + + } + /> +) diff --git a/src/components/campaign/content/ContentPieceTable.tsx b/src/components/campaign/content/ContentPieceTable.tsx index bff4e3fc..8ea43e0b 100644 --- a/src/components/campaign/content/ContentPieceTable.tsx +++ b/src/components/campaign/content/ContentPieceTable.tsx @@ -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). Peça, + head: Peça, cellClassName: 'xl:whitespace-normal', cell: (piece) => { const Icon = TYPE_ICON[piece.type] @@ -88,7 +93,7 @@ export const ContentPieceTable = ({ { id: 'processing', label: 'Processamento', - head: Processamento, + head: Processamento, cell: (piece) => (
@@ -105,13 +110,13 @@ export const ContentPieceTable = ({ { id: 'publication', label: 'Publicação', - head: Publicação, + head: Publicação, cell: (piece) => , }, { id: 'circulation', label: 'Circulação', - head: Circulação, + head: Circulação, cell: (piece) => ( + Próxima ação ), cellClassName: 'text-right', - cell: (piece) => - piece.canRetry ? ( - ( +
+ {piece.canRetry ? ( + + ) : ( + + )} + - ) : ( - - ), +
+ ), }, ]} /> diff --git a/src/components/campaign/recording/RecordingDeleteDialog.tsx b/src/components/campaign/recording/RecordingDeleteDialog.tsx index e6e538bf..dcbb1c8b 100644 --- a/src/components/campaign/recording/RecordingDeleteDialog.tsx +++ b/src/components/campaign/recording/RecordingDeleteDialog.tsx @@ -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(null) - - const confirmDelete = async () => { - setSubmitting(true) - setError(null) - - try { - const { ok, payload } = await deleteCampaignJson( - 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) +}) => ( + +