feat(C236): publicar, despublicar e apagar em lote na Central de Conteúdos

A lista /campanha/comunicacao/conteudos ganha um modo de seleção opt-in
que envolve os dois corpos (tabela e card list) com um estado client
compartilhado, chaveado pela URL canônica (a seleção morre em
página/filtro e nunca cresce para além da página visível). Com seleção
ativa, a barra flutuante aplica o mesmo veredito unitário à seleção:
Publicar/Despublicar agem direto com recibo honesto; Apagar sempre
confirma no CampaignDeleteDialog reusado (novo caminho onConfirm
aditivo), avisando os links públicos só quando a seleção carrega
publicadas.

O transporte é uma rota única (POST .../conteudos/lote,
campaignJsonMutationRoute, teto de 50 ids) que itera server-side as ações
donas: cada peça mantém a própria transação, o gate fresco e a limpeza
best-effort de mídia; a falha de uma nunca reverte as demais e o recibo
separa afetadas x falhas (2 de 3 peças apagadas. 1 falhou.).

Sem migration, sem Consent novo, sem rota pública (S27) e sem mudar a
semântica do C222. Cobertura: int do lote (papéis, dedupe, mídia, evento,
tag, falha parcial, schema), unit do builder/barra/seleção e e2e HTTP.

Design tier: DEGRADED (artefato de origem sem certificação frontier) —
crítica do designer aplicada no escopo do item; entrega bloqueada para
sign-off humano antes do PR. Related #1388
This commit is contained in:
Francisco Solla 2026-09-29 21:58:20 -03:00
parent ce055ad3d8
commit 10327a7451
27 changed files with 1653 additions and 53 deletions

View file

@ -0,0 +1 @@
**Central de Conteúdos — publicar, despublicar e apagar em lote (2026-09-29):** o kill switch morava ficha a ficha e apagar era uma confirmação por linha; o uso real chega em blocos. A lista `/campanha/comunicacao/conteudos` ganhou um modo de seleção opt-in (checkbox no topo da lista, coluna `mandatory` fora do seletor de colunas, tint `primary/5` na linha) que envolve os dois corpos da lista — tabela desktop e card list mobile — com **um** estado de seleção client; a seleção é só da página visível, morre ao trocar de página/filtro (o provider é chaveado pela URL canônica) e ids que saem da página deixam de contar. Com seleção ativa a barra flutua no rodapé (`sticky`, 16px acima do FAB no mobile, recuo à direita no desktop) com `N selecionada(s)` e `Publicar`/`Despublicar`/`Apagar`: os reversíveis agem direto, o irreversível sempre confirma no `CampaignDeleteDialog` reusado via um caminho `onConfirm` aditivo — o aviso de links públicos aparece só quando a seleção carrega publicadas (singular de uma peça reusa a copy do C222). O lote é o gesto unitário repetido: `POST .../conteudos/lote` (`campaignJsonMutationRoute`, teto de 50 ids) itera server-side as ações donas, cada item na própria transação e no próprio gate, e devolve `{ affected, failures[] }` — a falha de uma peça nunca reverte as demais e o recibo inline (cena 5 do design) nunca diz "3 apagadas" com falha: `2 de 3 peças apagadas. 1 falhou.` (singular/plural no builder puro, `aria-live` no sucesso e `role="alert"` na parcial). Invariantes intocados: access fail-closed da vertical, sem routine por linha nova, sem `Consent`, sem migration, sem rota pública (S27), `ContentEvent` preservado e a semântica do C222 (slug/`publishedAt`/arquivo preservados) reusada literalmente. Cobertura: int do lote (papéis, dedupe, mídia, evento, tag, falha parcial, schema), unit do builder/barra/seleção (incl. card frame) e e2e HTTP do lote. **Design tier: DEGRADED** (artefato de origem sem certificação frontier) — a crítica do designer contra o app renderizado aplicou a adaptação do FAB/reserva de rolagem/clamp/a11y e a Issue foi flipada para `blocked` aguardando sign-off humano antes do PR (screenshots em `docs/plans/central-conteudos-acoes-em-lote-ui-critique/`).

View file

@ -0,0 +1,159 @@
# Impl: C236 — Central de Conteúdos — publicar, despublicar e apagar em lote
Status: aprovado (executado; entrega bloqueada aguardando sign-off do design DEGRADED)
Atualizado em: 2026-09-29
Issue: #1388
Intenção: docs/plans/central-conteudos-acoes-em-lote.md
Appetite restante: herdado (~1–1,5 dia eng) — o corte de produto (seleção só da página visível, sem job, sem agendamento, sem edição de campos) mantém o escopo dentro do appetite.
## Leitura da intenção
- **Outcome:** a assessoria seleciona várias peças **da página visível** na lista da Central (`/campanha/comunicacao/conteudos`) e aplica o **mesmo veredito unitário** — `Publicar`, `Despublicar` ou `Apagar` — num gesto, com recibo honesto (afetadas × falhas) e a lista refletindo o desfecho; a seleção morre ao trocar de página/filtro e pós-ação.
- **O que NÃO negociar:** o lote é o gesto unitário repetido, sem regra nova — elegibilidade `processando`/`falhou` incluída; `Publicar`/`Despublicar` agem sem confirmação (reversíveis) e `Apagar` sempre confirma avisando links públicos quando há publicadas; falha parcial é honesta e não reverte as demais; audiência `communicator|coordinator|candidate` (`advisor`/`leader` negados, fail-closed), sem papel novo; sem rota pública nova (S27), sem mudar a semântica do C222 (slug canônico/`publishedAt`/arquivo preservados no despublicar, mídia privada best-effort, `ContentEvent` nunca limpo); nenhum `Consent` novo.
- **O que reavaliar (hipóteses da intenção confirmadas no código):** o `CampaignTable` é server e não tem API de seleção (`CampaignTable.tsx:27-63,155-224`) — a seleção entra por ilha client, não por mudança de shell; a lista é renderizada 2× por breakpoint (card/tabela) — um único estado compartilhado via provider; a ação unitária já concentra semântica e transação (reuso literal, não reinterpretação).
## Abordagem recomendada
```mermaid
flowchart LR
Page["page.tsx (RSC)<br/>conteudos"] --> Provider["ContentPieceSelectionProvider<br/>(client: modo, ids, pending, receipt)"]
Provider --> Mode["ContentPieceSelectionModeControl"]
Provider --> Table["ContentPieceTable (RSC)<br/>coluna seleção oculta fora do modo"]
Provider --> Cards["ContentPieceCardList (RSC)"]
Table --> Boxes["ContentPieceRowCheckbox /<br/>ContentPieceSelectAllCheckbox"]
Cards --> Frame["ContentPieceCardSelectionFrame"]
Provider --> Bar["ContentPieceSelectionBar"]
Bar -->|Publicar / Despublicar| BatchRoute["POST .../conteudos/lote<br/>campaignJsonMutationRoute"]
Bar --> DeleteDialog["CampaignDeleteDialog<br/>(confirm = batch)"]
DeleteDialog -->|Apagar| BatchRoute
BatchRoute --> BatchAction["applyContentPieceBatchActionForActor<br/>loop por item, transação por item"]
BatchAction --> PublishAction["setContentPiecePublishedForActor"]
BatchAction --> DeleteAction["deleteContentPieceForActor"]
BatchAction --> Outcome["outcome { action, affected, failures }"]
Outcome --> Receipt["ContentPieceBatchReceipt<br/>(inline, status/alert)"]
Outcome --> Refresh["clear() + router.refresh()"]
```
**Opções consideradas:** A | B | C
**Recomendação:** **B** — uma rota única `POST .../conteudos/lote` que itera server-side reusando as ações unitárias, cada item na própria transação, e devolve um recibo estruturado. Reuso literal da semântica (slug/`publishedAt`/arquivo/mídia/tag), teto de lote fixado em schema (max 50, precedente `contentPieceStatusRequestSchema`), recibo e falha parcial testáveis no int, transporte único pelo wrapper `campaignJsonMutationRoute`.
**Rejeitadas:** **A** (cliente dispara N fetches unitários) porque espalha no cliente o laço, o recibo e a agregação de falhas, faz N round-trips e não fixa o teto do conjunto em nenhum contrato; **C** (transação única do lote) porque a intenção fixa falha parcial honesta — uma falha não pode reverter as demais —, prolongaria a transação e mudaria a semântica pós-commit da mídia privada.
### Decisões de engenharia (caras de reverter)
#### 1. Transporte do lote
**Opções:** A) o cliente dispara N fetches às rotas unitárias existentes | B) rota nova única `POST .../conteudos/lote` com `{ action, contentPieceIds }` iterando server-side o reuso das ações unitárias | C) transação única para o lote.
**Recomendação:** **B** — a rota entra por `campaignJsonMutationRoute` (guard de convenção em `tests/unit/codebaseConventions.unit.spec.ts:251`), o schema fixa o teto (`.min(1).max(50)`), e a action `applyContentPieceBatchActionForActor` chama `setContentPiecePublishedForActor` / `deleteContentPieceForActor` **uma vez por id** — cada chamada mantém sua própria transação (`withPayloadTransaction`), o gate fresco e a limpeza de mídia pós-commit (`contentPieces.ts:163-189,239-278`). O recibo (`affected` + `failures[]`) nasce no domínio e é testável no int; o cliente só traduz para os literais do design.
**Alternativas rejeitadas:** A porque duplica no cliente o laço/agregação, multiplica round-trips (até 25) e não tem contrato de teto nem recibo de domínio; C porque contraria a decisão de produto de falha parcial honesta, cria transação longa e conflita com o `onPayloadTransactionCommit` da mídia por item.
**Detalhe de execução:** o loop é sequencial (determinístico, gentil com o banco; 25 itens máx. da página). Se a latência do lote doer (medida no e2e), o gatilho de revisitação é paralelizar com `Promise.allSettled` mantendo o mesmo outcome — barato e local.
#### 2. Estado de seleção compartilhado (tabela + card list)
**Opções:** A) um client provider via `children` (RSC preservado), com estado de ids, modo, pending e recibo, resetado por `key` da URL canônica da lista | B) Context API com row registry (cada linha registra/desregistra no provider) | C) estado no servidor via URL (`?sel=1,2,3`).
**Recomendação:** **A** — `ContentPieceSelectionProvider` (client) recebe uma projeção serializável das rows (`{ id, status, publicPath }`) e envolve os dois corpos renderizados pela RSC (`ContentPieceCardList` e `ContentPieceTable`), que continuam server; as ilhas de checkbox consomem o contexto. O provider é chaveado pela URL canônica da lista (`buildContentPieceListHref(data.state, data.state.page)`), então trocar página/filtro **remonta** e zera seleção, modo e recibo. Pós-ação, o provider chama `clear()` e `router.refresh()`. O poll de status (`ContentPieceStatusRefresher.tsx:48`) refresca sem mudar a key — a seleção sobrevive a uma renovação de status, que é o comportamento correto (os ids seguem válidos).
**Alternativas rejeitadas:** B porque é maquinaria de sincronização sem necessidade (as rows já chegam como prop) e dificulta "selecionar todas" e o aviso de publicadas; C porque contraria "a seleção morre ao navegar", polui o contrato da URL canônica com ordenação/limites e transforma cada checkbox numa navegação com re-render do servidor.
#### 3. Coluna de seleção no `CampaignTable` server
**Opções:** A) coluna definida sempre em `columns` com `mandatory: true`, head/cell como ilhas client que leem o contexto, e a coluna nascendo oculta por CSS (`data-selection-mode` no root do provider + variantes `group-data-[selection-mode=true]:table-cell`); a lista do column picker **não** ganha a coluna | B) checkbox permanente, sem modo | C) dar API de seleção ao `CampaignTable` (ou torná-lo client).
**Recomendação:** **A** — o shell da tabela não muda (`CampaignTable.tsx` segue server e data-driven); `mandatory: true` protege contra cookie antigo (`campaignColumnVisibility.ts:184-192`) e a coluna não entra no picker em `page.tsx:60-66` porque é chrome do modo de seleção, não dado. O tint da linha selecionada usa `rowClassName: 'has-[[data-selection-state=selected]]:bg-primary/5'` (o `has-[...]` já é usado em `ui/Table.tsx:57,67` e `ui/field.tsx`), e a ilha marca `data-selection-state` no seu root.
**Alternativas rejeitadas:** B porque contraria o design aprovado (repouso sem ruído) e a questão de produto A da intenção; C porque acopla um concern de uma superfície ao sistema de tabela compartilhado — a seleção é local da Central, não do shell (depth errado).
#### 4. Barra de ações e confirmação de apagar
**Opções:** A) barra client dentro do provider (`sticky bottom-4 z-20`, classes do artefato) e confirmação reusando `CampaignDeleteDialog` estendido de forma **aditiva** com um caminho `onConfirm`/`onSuccess` (union: `endpoint` XOR `onConfirm`) | B) criar um `ContentPieceBatchDeleteDialog` irmão, duplicando o chrome | C) montar o `AlertDialog` inline na barra.
**Recomendação:** **A** — a barra é a única dona do gesto (contagem, botões, pending, recibo) e o diálogo continua com **um só dono de chrome/estado** (`CampaignDeleteDialog.tsx:54-127`: ícone circular destrutivo, rodapé `muted/50`, `Cancelar` outline, `Apagar` vermelho cheio, erro dentro do diálogo, spinner/disabled). A extensão não toca os 3 call sites atuais (cut, recording, peça): `onConfirm: () => Promise<{ ok: true } | { ok: false; message: string }>` e `onSuccess?: () => void`; no caminho novo, o machine chama `onConfirm()` em vez do DELETE por endpoint e, no sucesso, mantém o `router.refresh()` e chama `onSuccess`. A barra computa título/descrição: `1` selecionada reusa o C222 (`Apagar esta peça?` + link nomeado quando publicado); `N > 1` usa `Apagar N peças selecionadas?` com o aviso de publicadas × só "não pode ser desfeita".
**Alternativas rejeitadas:** B porque duplica um machine extraído justamente para não ter a 4ª cópia (C183→C199→C222); C porque recopia chrome, submitting/erro/disabled que o machine já owns.
#### 5. Recibo e estado de erro
**Opções:** A) banner inline no topo da lista (ilha client no provider; `role="status"`/`aria-live` no sucesso, `role="alert"` na falha parcial) | B) toast sonner (`useCampaignFormSuccessToast`, layout `(campaign)/layout.tsx:52`) | C) mensagem dentro da barra.
**Recomendação:** **A** — o design do gate (cena 5) fixa o recibo inline no topo da lista, nunca sobre ela; o precedente sonner é de form action, não de lista, e um `role="alert"` de falha parcial precisa ficar no contexto da lista, não num canto transitório. Sucesso some sozinho (~6 s) ou no X; falha parcial/erro fica até fechar ou até a próxima ação. Os literais saem de um builder puro (singular/plural seguindo `TourComposerForm.tsx:180` e o literal do plano): `3 peças publicadas`, `1 peça despublicada`, `2 de 3 peças apagadas. 1 falhou.`; a UI não renderiza mensagem por peça (fora do design), mas o outcome carrega `failures[]` para teste e diagnóstico. Erro de rota no `Publicar`/`Despublicar` vira banner de erro e **mantém** a seleção (nada mudou); no `Apagar` o erro fica dentro do diálogo (como no C222).
**Alternativas rejeitadas:** B porque o design fixa o recibo inline e o toast some sozinho — a falha parcial não pode ser silenciosa; C porque a barra sai de cena quando a seleção zera e o recibo precisa sobreviver à limpeza da seleção.
#### 6. Migração, access e Consent
**Opções:** A) sem migration, reusando access existente e o gate fresco por ação | B) migration nova | C) mecanismo de consentimento/auditoria novo.
**Recomendação:** **A** — nenhuma coleção/campo muda (o lote só chama ações existentes), nenhuma `Consent` nova, nenhuma role nova. `applyContentPieceBatchActionForActor` repete o gate fresco (`canReadCommunicationCatalog`) **antes** do loop — role/auth negados falham a requisição inteira (400/401), não viram falha parcial — e cada item reusa a ação que re-checa o gate e termina na collection access (`canUpdateContentPiece`/`canDeleteContentPiece`, `utilities/access/contentPieces.ts:40-58`) como barreira final.
**Alternativas rejeitadas:** B por desnecessária (nenhum schema); C por sair do escopo e do invariante "nada de Consent novo".
### Componentes / mudanças
- **`ContentPieceSelectionProvider`** (`src/components/campaign/content/ContentPieceSelectionProvider.tsx`, novo): contexto client com `selectionMode`, `selectedIds` (`number[]` + `Set` memoizado, precedente `TourComposerForm.tsx:137-141`), `pendingAction`, `receipt`; ações `enter/exit/toggle/toggleAll/clear/applyBatch`; `resetKey` por `key`; transporta o lote com `postCampaignJson` (`lib/campaignJsonRequest.ts:12-26`) e orquestra `clear()` + `router.refresh()`.
- **`ContentPieceSelectionControls`** (`.../ContentPieceSelectionControls.tsx`, novo): ilhas `ContentPieceSelectionModeControl` (botão `Selecionar` × badge `Modo de seleção` + `Cancelar seleção`), `ContentPieceRowCheckbox` (tabela, com `data-selection-state`), `ContentPieceSelectAllCheckbox` (`indeterminate`), `ContentPieceCardSelectionFrame` (envelope client do card mobile com checkbox 44 px + `ring-1 ring-primary/25`; corpo do card continua RSC via `children`).
- **`ContentPieceSelectionBar`** (`.../ContentPieceSelectionBar.tsx`, novo): barra com `N selecionada(s)`, `Publicar`/`Despublicar`/`Apagar`, pending (`aria-busy`), consumindo o provider; monta o `CampaignDeleteDialog` do batch.
- **`ContentPieceBatchReceipt`** (`.../ContentPieceBatchReceipt.tsx`, novo): banner inline do recibo (cena 5 do artefato).
- **`CampaignDeleteDialog`** (`src/components/campaign/shared/CampaignDeleteDialog.tsx`): extensão aditiva do union `endpoint` XOR `onConfirm` + `onSuccess` (decisão 4); call sites atuais intocados.
- **`ContentPieceTable`** (`src/components/campaign/content/ContentPieceTable.tsx`): coluna `selection` `mandatory` com head/cell ilhas, CSS de modo, `rowClassName` de tint; nenhuma mudança no `CampaignTable`.
- **`ContentPieceCardList`** (`src/components/campaign/content/ContentPieceCardList.tsx`): card envolvido pelo frame client de seleção (sem mudar o conteúdo RSC).
- **`page.tsx`** (`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx`): provider em volta dos dois corpos + mode control + recibo + barra; projeção `selectionRows` e `resetKey` calculados da `data.state`; coluna `selection` fora do `toCampaignColumnPickerColumns`.
- **`contentPieceBatch`** (`src/lib/contentPieceBatch.ts`, novo): vocabulário `publicar|despublicar|apagar`, labels e builder puro do recibo (singular/plural/parcial).
- **Schema** (`src/lib/schemas/contentPiece.ts`): `contentPieceBatchRequestSchema` (`action` enum + `contentPieceIds` `.min(1).max(50)`, precedente `:133-135`).
- **Action** (`src/app/(campaign)/campanha/actions/contentPieces.ts`): `applyContentPieceBatchActionForActor` com gate fresco, loop sequencial, `mapCampaignFormActionError` + `CONTENT_PIECE_SAFE_MESSAGES` para mascarar falha por item.
- **Rota** (`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/lote/route.ts`, novo): `POST = campaignJsonMutationRoute(...)` devolvendo `{ status: 'success', outcome }`; convive com `[id]` como `enviar`/`status`/`link` já convivem. Sem rota DELETE nova.
- **Contratos/wire** (`.../conteudos/types.ts`): `ContentPieceBatchResponse`/`ContentPieceBatchOutcome`/`ContentPieceBatchFailure`; **paths** (`src/lib/campaignPaths.ts`): `CAMPAIGN_CONTENT_PIECE_BATCH_HREF`.
- **Migration:** N/A — nenhuma coleção/campo/índice muda; nenhuma migration criada (skill `payload-migrations` não é acionada).
- **Access / Consent:** sem mudança de access; sem `Consent` novo; fail-closed preservado.
- **UI:** Impeccable **B** — encaixe na lista existente. Port **classe-a-classe** do artefato DEGRADED `docs/plans/central-conteudos-acoes-em-lote-ui-design.html`; shells reusados: `CampaignPageShell`, `CampaignListPendingBoundary/Results`, `CampaignListFooter`, `CampaignTable`/`CampaignTableHead`, `AlertDialog`, `Button`, `Checkbox`, `Spinner`, badges. Classes-chave a preservar: barra `bar-shadow flex items-center justify-between gap-4 rounded-xl border border-border bg-card px-3 py-2.5` (mobile `p-3` com `grid grid-cols-3 gap-2`); contador `text-sm font-medium tabular-nums`; `Publicar` `bg-primary text-primary-foreground`, `Despublicar` outline `hover:bg-muted`, `Apagar` `bg-destructive/10 text-destructive hover:bg-destructive/20`; checkbox `size-4 rounded-[4px]` com hit-area e alvo `size-9` (tabela)/`size-11` (card); linha `bg-primary/5`; card `ring-1 ring-primary/25`; badge do modo `bg-primary/10 text-primary`; recibos `rounded-lg border` com check verde só no ícone e `border-destructive/40` na falha; `min-h-11` em todo alvo; `motion-reduce:animate-none` no spinner. **Sign-off humano obrigatório no PR** (design DEGRADED).
- **Testes:** int em `tests/int/contentPiece.int.spec.ts` (novo describe de lote), unit novos `tests/unit/contentPieceBatchReceipt.unit.spec.ts` e `tests/unit/contentPieceSelection.unit.spec.tsx` (template `contentPieceDeleteDialog.unit.spec.tsx`: mock de `next/navigation` + fetch), e2e no describe de content pieces de `tests/e2e/campaignSpeechAcervo.e2e.spec.ts`.
### Dados → forma (se aplicável)
- **Não se aplica** — a intenção fixa "não apresento dados": nenhum painel, métrica, dashboard ou telemetria. A forma do recibo (banner inline com contagens do outcome) é a da cena 5 do design; o número é o resultado da própria ação, não leitura agregada nova.
## Fases verificáveis
1. **Tracer / server (schema → action → rota)** — quota ~0,5 dia. `contentPieceBatchRequestSchema` + `contentPieceBatch` (builder do recibo) + `applyContentPieceBatchActionForActor` + `lote/route.ts` + `CAMPAIGN_CONTENT_PIECE_BATCH_HREF` + tipos do wire; int cobrindo matriz de papéis (communicator/coordinator/candidate/admin × advisor/leader), publicar/despublicar em lote preservando slug/`publishedAt`/arquivo, apagar em lote derrubando mídia (best-effort) e mantendo `ContentEvent`, falha parcial honesta (`affected` + `failures` com `NOT_FOUND`), teto/limite do schema e bust da tag `contentPieces`; e2e de rota (HTTP) com sucesso, falha parcial e negado.
2. **UI** — quota ~0,5–0,75 dia. Provider + mode control + ilhas de checkbox (tabela e card) + coluna `mandatory` com CSS de modo + tint + barra + extensão aditiva do `CampaignDeleteDialog` + recibo; unit de seleção (modo, contagem `1 selecionada`/`N selecionadas`, POST com `{ action, contentPieceIds }`, limpeza pós-ação, recibo de sucesso e parcial, diálogo plural/singular com aviso de publicadas, erro de rota mantendo seleção) e unit do builder do recibo; port classe-a-classe revisado contra o artefato.
3. **Gates** — `pnpm gate:fast`; push via `pnpm push` (o CI PR roda o cascade curado; o deploy roda a suíte full). Sign-off humano do design DEGRADED no PR.
## Rabbit holes / Não escopo (engenharia)
- Seleção cross-page/"todos os filtrados", dedupe, contagem global — corte explícito da intenção (só a página visível; 25).
- Seleção persistente em `localStorage`/cookie ou modo de seleção sobrevivendo à navegação — a key da URL zera tudo de propósito.
- Marcar as peças que falharam como "retry da seleção", progresso por item, fila/retomada/cancelamento — o recibo é uma frase, não um painel.
- Toast e banner ao mesmo tempo; auditoria/telemetria de quem agiu; undo/lixeira.
- Otimismo nas rows (atualizar a linha antes do refresh) — o refresh do RSC é a verdade; o estado de envio cobre o feedback.
- "Selecionar todas" também no card list mobile (o design não mostra; a barra mobile não ganha header de seleção).
- Adicionar API de seleção ao `CampaignTable` ou converter a tabela em client — o shell fica intocado.
- Migrations ("já que mexe na lista, ajusta X"), novos campos em `contentPiece`, `Consent` novo, mudar o gesto unitário (slug/`publishedAt`/arquivo/mídia/`ContentEvent`) ou as rotas públicas S27.
- Editar `docs/plans/central-conteudos-acoes-em-lote.md` (intenção) ou o design HTML.
- **Adiado com gatilho (triage /simplify):** a frase singular de apagar ("O link público `<path>` deixa de funcionar… Esta ação não pode ser desfeita.") é paralela entre a barra de lote e o `ContentPieceDeleteDialog` (C222) — o dono único da copy sai quando houver um 3º call site ou quando a copy/design do aviso mudar (DRY de 2 sites não vira item).
- **Explicitamente fora (triage /simplify):** sombra da barra como valor arbitrário (port classe-a-classe do `bar-shadow` do artefato; tokenização só se a regra do DESIGN.md §7 for aprovada no sign-off do PR); ids obsoletos no estado do provider (filtrados por `visibleSelectedIds` em todo consumidor); esconder o entry "Selecionar" com lista vazia já resolvido (`page.tsx` condiciona a `data.rows.length > 0`).
## Riscos e mitigação
- **Estado client sobrevive a refresh/navegação** — a key do provider é a URL canônica da lista (`page`+filtros): navegação remonta e zera; o refresh pós-ação/poll não muda a key (seleção pós-ação é limpa por `clear()`; renovação de status preserva os ids válidos). Risco residual: o poll renovar rows durante a seleção — aceitável e coberto por testar que a seleção sobrevive ao refresh de mesma key.
- **Dois corpos (tabela + card) com um estado** — provider único envolvendo os dois; cada corpo renderiza sua ilha; teste unitário garante que marcar no card reflete na barra e no select-all (e vice-versa) — na prática o mesmo contexto.
- **`CampaignTable` server sem API de seleção** — coluna estática + CSS `data-selection-mode` + ilhas; `mandatory` contra cookie; `ui/Table` já remove `pr-0` quando há `role=checkbox`. Se um dia o shell ganhar seleção nativa, revisitar (barato).
- **Falha parcial silenciosa** — o outcome sempre separa `affected` de `failures` e o recibo nunca diz "N apagadas" com falha; o literal parcial é o do plano (`2 de 3 peças apagadas. 1 falhou.`); `role="alert"` na parcial.
- **Mídia privada best-effort / `ContentEvent` intocado** — o lote não toca nesses caminhos: reusa `deleteContentPieceForActor` (transação + `onPayloadTransactionCommit` + `overrideAccess` intencional só da mídia) e não faz nenhuma escrita em `contentEvent`.
- **Latência de até 25 itens em loop sequencial** — aceitável para um gesto de mesa com teto da página; gatilho de revisitação: se o e2e/int acusar latência, paralelizar com `Promise.allSettled` mantendo o outcome.
- **Convenção de rotas** — POST novo usa `campaignJsonMutationRoute` (guard `codebaseConventions:251`); nenhuma rota DELETE nova (guard `:271` fica satisfeito por não existir).
- **Sessão/role expirada no meio do lote** — gate e auth são checados antes do loop; negado vira 400/401 inteiro, não falha parcial disfarçada.
- **Dupla renderização a11y** — o corpo oculto por breakpoint sai da árvore de acessibilidade (`display:none`); labels dos checkboxes incluem o título da peça (`Selecionar <título>`), select-all `Selecionar todas as peças desta página`.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: seleção por página nos dois corpos; barra só com seleção; `Publicar`/`Despublicar` sem confirmação e `Apagar` sempre confirmando (com aviso de publicadas); recibo com afetadas × falhas e sem sucesso parcial silencioso; elegibilidade idêntica ao unitário; seleção morre em página/filtro e pós-ação; audiência `communicator|coordinator|candidate` fail-closed; nada de rota pública.
- [ ] Invariantes AGENTS/engineering-standards: Local API sempre com `user` + `overrideAccess: false` (herdado das ações reusadas); escritas multi-collection transacionais com `req` (herdado); copy pt-BR / identificadores em inglês; sem `Consent` novo; sem tocar S27; sem editar migration existente nem criar migration (N/A); "edite o dono, não crie o gêmeo" (diálogo e shells reusados).
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: int do lote (papéis, transações por item, mídia, `ContentEvent`, tag, falha parcial, schema), unit da barra/seleção (transporte, limpeza, recibos, diálogo) e unit do builder puro; e2e de rota no arquivo existente.
- [ ] Gates: `pnpm gate:fast` verde; push via `pnpm push`; sign-off humano do design DEGRADED registrado no PR.
## Self-score (decision-quality, gate ≥4)
**5/5** — (1) decisões caras com Opções A|B|C e rejeitadas explícitas (transporte, estado, coluna, barra/diálogo, recibo, migration/access); (2) cabe no appetite herdado (~1–1,5 dia; server ~0,5, UI ~0,5–0,75, sem rabbit hole); (3) rabbit holes de engenharia nomeados (cross-page, persistência, progresso, toast duplo, API de seleção no shell); (4) depth check: reusa `CampaignDeleteDialog`, `CampaignTable`, `Checkbox`, `postCampaignJson`, `campaignJsonMutationRoute`, `withPayloadTransaction`/ações donas, `CampaignListResults` e o builder puro centraliza o recibo; (5) outcome da intenção intocado — nenhuma semântica unitária, access ou rota pública mudou.
## Crítica de design (trigger c, 2026-09-29)
**Veredito: DEGRADED — não certificado.** O designer frontier (`openai/gpt-5.6-sol`) rodou a crítica contra o app renderizado (screenshots 390/1280 em `docs/plans/central-conteudos-acoes-em-lote-ui-critique/`), mas não certifica: o artefato de origem é DEGRADED e exige sign-off humano. Ajustes do designer:
1. **FAB × barra (trigger b, aplicado):** `ContentPieceSelectionBar` → `sticky bottom-32 z-20 mt-4 md:bottom-4 md:mr-16` (no mobile a barra sobe acima do `CampaignQuickActionsFab`; no desktop recua à direita).
2. **Reserva de rolagem (aplicado):** `CampaignListResults` da lista ganha `group-data-[selection-mode=true]:pb-64 md:group-data-[selection-mode=true]:pb-28` — durante a seleção a barra não oculta o foco/último item.
3. **Diálogo translúcido/overlay (deferido ao sign-off):** tocar `AlertDialog` (overlay `bg-black/10` + blur, `max-w-xs` mobile, `leading` da descrição) muda o chrome de **todas** as superfícies do app (corte, gravação, C222) — decisão humana, registrada no comentário da Issue.
4. **Largura do diálogo de lote (aplicado, escopado):** novo `contentClassName` aditivo no `CampaignDeleteDialog`; a barra passa `max-w-sm` (358px em 390) sem tocar os call sites existentes.
5. **Clamp de títulos (aplicado):** `line-clamp-2 break-words` no título da tabela e do card.
6. **Recibo a11y (aplicado):** sucesso com `aria-live="polite" aria-atomic="true"`; fechar com `focus-visible:ring-3 focus-visible:ring-ring/50`.
7. **Semântica aprovada preservada:** rascunhos sem aviso de links; seleção com publicadas com o aviso — inalterado.
**Estado da entrega:** implementação, testes (int/unit/e2e) e `pnpm gate:fast` verdes. Com `--auto` não há humano para o sign-off, então o fluxo **não abre PR**: Issue comentada e flipada para `blocked`. A proposta de evolução do `DESIGN.md` §7 (modo de seleção de lista) continua proposta, dependente do sign-off.

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View file

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { applyContentPieceBatchActionForActor } from '@/app/(campaign)/campanha/actions/contentPieces'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
contentPieceBatchRequestSchema,
} from '@/lib/schemas/contentPiece'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { ContentPieceBatchResponse } from '../types'
export const dynamic = 'force-dynamic'
/**
* C236 — the batch gateway of the Central list: one request carries the verb
* and the ids of the visible page; the action repeats the unit gesture per
* piece and answers the honest outcome (affected × failures). The loop belongs
* to the server, next to the semantics it reuses — the client only renders the
* receipt.
*/
export const POST = campaignJsonMutationRoute(
{
bodySchema: contentPieceBatchRequestSchema,
safeMessages: [CONTENT_PIECE_FORBIDDEN_MESSAGE],
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const outcome = await applyContentPieceBatchActionForActor(body)
return NextResponse.json<ContentPieceBatchResponse>({ status: 'success', outcome })
},
)

View file

@ -4,8 +4,12 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { AddContentPieceLinkDialog } from '@/components/campaign/content/AddContentPieceLinkDialog'
import { ContentPieceBatchReceipt } from '@/components/campaign/content/ContentPieceBatchReceipt'
import { ContentPieceCardList } from '@/components/campaign/content/ContentPieceCardList'
import { ContentPieceFilters } from '@/components/campaign/content/ContentPieceFilters'
import { ContentPieceSelectionBar } from '@/components/campaign/content/ContentPieceSelectionBar'
import { ContentPieceSelectionModeControl } from '@/components/campaign/content/ContentPieceSelectionControls'
import { ContentPieceSelectionProvider } from '@/components/campaign/content/ContentPieceSelectionProvider'
import { ContentPieceStatusRefresher } from '@/components/campaign/content/ContentPieceStatusRefresher'
import { ContentPieceTable } from '@/components/campaign/content/ContentPieceTable'
import { ContentPieceUploadDialog } from '@/components/campaign/content/ContentPieceUploadDialog'
@ -20,6 +24,7 @@ import {
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import type { ContentPieceSelectionRow } from '@/lib/contentPieceBatch'
import { CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE } from '@/lib/schemas/contentPiece'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
@ -57,6 +62,19 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP
processingStatus: row.processingStatus,
}))
// C236 — the serializable projection the client selection provider needs:
// the delete warning (status + public path) and the ids of "selecionar
// todas" (the visible page). The checkbox labels come from the row islands,
// which already render the piece title.
const selectionRows: ContentPieceSelectionRow[] = data.rows.map((row) => ({
id: row.id,
status: row.status,
publicPath: row.publicPath,
}))
// The selection is page-scoped: the canonical list URL is the provider's key,
// so navigating to another page/filter remounts it and forgets the selection.
const selectionResetKey = buildContentPieceListHref(data.state, data.state.page)
const columns = toCampaignColumnPickerColumns([
{ id: 'piece', label: 'Peça', mandatory: true },
{ id: 'processing', label: 'Processamento' },
@ -142,24 +160,31 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP
</div>
) : null}
<CampaignListResults>
<ContentPieceCardList rows={data.rows} empty={emptyState} />
<ContentPieceTable
rows={data.rows}
columnVisibility={columnVisibility}
empty={emptyState}
/>
{data.rows.length > 0 ? (
<CampaignListFooter
totalDocs={data.totalDocs}
singular="peça"
plural="peças"
page={data.state.page}
totalPages={data.totalPages}
hrefForPage={(page) => buildContentPieceListHref(data.state, page)}
<ContentPieceSelectionProvider key={selectionResetKey} rows={selectionRows}>
{data.rows.length > 0 ? <ContentPieceSelectionModeControl /> : null}
<ContentPieceBatchReceipt />
<CampaignListResults className="group-data-[selection-mode=true]:pb-64 md:group-data-[selection-mode=true]:pb-28">
<ContentPieceCardList rows={data.rows} empty={emptyState} />
<ContentPieceTable
rows={data.rows}
columnVisibility={columnVisibility}
empty={emptyState}
/>
) : null}
</CampaignListResults>
{data.rows.length > 0 ? (
<CampaignListFooter
totalDocs={data.totalDocs}
singular="peça"
plural="peças"
page={data.state.page}
totalPages={data.totalPages}
hrefForPage={(page) => buildContentPieceListHref(data.state, page)}
/>
) : null}
</CampaignListResults>
<ContentPieceSelectionBar />
</ContentPieceSelectionProvider>
</CampaignListPendingBoundary>
</CampaignPageShell>
)

View file

@ -1,4 +1,5 @@
import type { ContentPieceProfileCandidate, ContentPieceViewModel } from '@/lib/contentPiece'
import type { ContentPieceBatchOutcome } from '@/lib/contentPieceBatch'
/** Wire contract of `POST .../conteudos/enviar` (C211) — raw-body upload. */
export type ContentPieceUploadResponse =
@ -51,3 +52,8 @@ export type ContentPieceAttachResponse =
export type ContentPieceDeleteResponse =
| { status: 'success'; deleted: true }
| { status: 'error'; message: string }
/** Wire contract of `POST .../conteudos/lote` (C236) — the batch verdict. */
export type ContentPieceBatchResponse =
| { status: 'success'; outcome: ContentPieceBatchOutcome }
| { status: 'error'; message: string }

View file

@ -12,13 +12,17 @@ import {
type ContentPieceCuratedField,
type ContentPieceViewModel,
} from '@/lib/contentPiece'
import type { ContentPieceBatchOutcome } from '@/lib/contentPieceBatch'
import { normalizeContentPiecePublicFigures } from '@/lib/publicFigureCatalog'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
CONTENT_PIECE_LINK_INVALID_MESSAGE,
CONTENT_PIECE_NOT_FOUND_MESSAGE,
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
CONTENT_PIECE_SAFE_MESSAGES,
contentPieceBatchRequestSchema,
contentPieceDeleteRequestSchema,
contentPieceLinkRequestSchema,
contentPieceProfileImportRequestSchema,
@ -26,12 +30,14 @@ import {
contentPieceRetryRequestSchema,
contentPieceStatusRequestSchema,
contentPieceUpdateRequestSchema,
type ContentPieceBatchRequest,
type ContentPieceStatusRequest,
type ContentPieceUpdateRequest,
} from '@/lib/schemas/contentPiece'
import type { SpeechTopic } from '@/lib/speechFacets'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import { reapStaleContentPiece } from '@/utilities/content/contentPieceJob'
import {
searchContentPieceLeaderOptions,
@ -278,6 +284,58 @@ export const deleteContentPieceForActor = async (input: {
return { deleted: true }
}
/**
* C236 — applies the same verdict to a bounded set of pieces. The batch is the
* unit gesture repeated, so every id goes through the unit action: its own
* transaction, its own fresh gate, its own post-commit media cleanup, its own
* collection access as the final barrier. A failure is named in the outcome and
* never reverts the pieces that succeeded (the product's honest partial
* failure); the communication gate is checked once before the loop, so a denied
* actor fails the whole request instead of turning into per-item noise. The
* loop is sequential on purpose: the page ceiling is 25 ids and determinism
* beats latency for a desk gesture.
*/
export const applyContentPieceBatchActionForActor = async (
input: ContentPieceBatchRequest,
): Promise<ContentPieceBatchOutcome> => {
const parsed = contentPieceBatchRequestSchema.parse(input)
const { actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
// A repeated id would act twice on the same row (the second one failing as
// not found), so the batch normalizes the selection before looping.
const contentPieceIds = [...new Set(parsed.contentPieceIds)]
const failures: ContentPieceBatchOutcome['failures'] = []
let affected = 0
for (const contentPieceId of contentPieceIds) {
try {
if (parsed.action === 'apagar') {
await deleteContentPieceForActor({ contentPieceId })
} else {
await setContentPiecePublishedForActor({
contentPieceId,
published: parsed.action === 'publicar',
})
}
affected += 1
} catch (error) {
failures.push({
contentPieceId,
message:
mapCampaignFormActionError({
error,
safeMessages: CONTENT_PIECE_SAFE_MESSAGES,
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
}).message ?? CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
})
}
}
return { action: parsed.action, affected, failures }
}
/**
* 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

@ -0,0 +1,60 @@
'use client'
import { CircleAlertIcon, CircleCheckIcon, XIcon } from 'lucide-react'
import { useEffect } from 'react'
import { useContentPieceSelection } from '@/components/campaign/content/ContentPieceSelectionProvider'
import { cn } from '@/lib/utils'
/** How long an all-success receipt stays before it fades on its own. */
const RECEIPT_AUTO_DISMISS_MS = 6_000
/**
* C236 — the honest receipt of the batch (approved design scene 5): a single
* inline banner at the top of the list, never over it. Success is one neutral
* phrase with a green icon and dismisses itself; a partial failure is an
* assertive `role="alert"` that always separates affected from failures and
* stays until it is closed.
*/
export const ContentPieceBatchReceipt = () => {
const { receipt, dismissReceipt } = useContentPieceSelection()
useEffect(() => {
if (receipt?.kind !== 'success') return
const timer = window.setTimeout(dismissReceipt, RECEIPT_AUTO_DISMISS_MS)
return () => window.clearTimeout(timer)
}, [receipt, dismissReceipt])
if (!receipt) return null
const success = receipt.kind === 'success'
return (
<div
role={success ? 'status' : 'alert'}
aria-live={success ? 'polite' : undefined}
aria-atomic="true"
className={cn(
'mt-4 flex items-start gap-2 rounded-lg border bg-card px-3 py-2.5',
success ? 'border-border' : 'border-destructive/40',
)}
>
{success ? (
<CircleCheckIcon className="mt-0.5 size-4 shrink-0 text-green-700" aria-hidden="true" />
) : (
<CircleAlertIcon className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden="true" />
)}
<p className={cn('flex-1 text-sm font-medium', !success && 'text-destructive')}>
{receipt.message}
</p>
<button
type="button"
aria-label="Fechar aviso"
className="grid size-9 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
onClick={dismissReceipt}
>
<XIcon className="size-4" aria-hidden="true" />
</button>
</div>
)
}

View file

@ -3,6 +3,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 { ContentPieceCardSelectionFrame } from '@/components/campaign/content/ContentPieceSelectionControls'
import {
ContentPieceProcessingBadge,
ContentPiecePublicationBadge,
@ -18,7 +19,9 @@ import type { ContentPieceRowViewModel } from '@/lib/contentPieceCirculation'
* 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. C222 adds "Apagar" beside the routine action
* (below it when the piece can be reprocessed), per the approved scene B.
* (below it when the piece can be reprocessed), per the approved scene B. C236
* hands the article box to the selection frame, which adds the 44px checkbox
* and the primary ring in selection mode without touching the body.
*/
export const ContentPieceCardList = ({
rows,
@ -40,21 +43,31 @@ export const ContentPieceCardList = ({
...(isLinkOnly ? [] : [piece.typeLabel, piece.durationLabel]),
]
return (
<article key={piece.id} className="rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<Link href={piece.detailHref} className="min-w-0 text-sm font-semibold hover:underline">
{piece.title}
</Link>
<ContentPieceProcessingBadge status={piece.processingStatus} className="shrink-0" />
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ContentPiecePublicationBadge status={piece.status} />
<span>
{meta.filter(Boolean).join(' · ')}
{piece.cityLabel ? ` · ${piece.cityLabel}` : ''}
</span>
</div>
<ContentPieceCardSelectionFrame
key={piece.id}
contentPieceId={piece.id}
title={piece.title}
header={
<>
<div className="flex items-start justify-between gap-3">
<Link
href={piece.detailHref}
className="block min-w-0 text-sm font-semibold break-words line-clamp-2 hover:underline"
>
{piece.title}
</Link>
<ContentPieceProcessingBadge status={piece.processingStatus} className="shrink-0" />
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ContentPiecePublicationBadge status={piece.status} />
<span>
{meta.filter(Boolean).join(' · ')}
{piece.cityLabel ? ` · ${piece.cityLabel}` : ''}
</span>
</div>
</>
}
>
{piece.processingStatus === 'processando' ? (
<>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
@ -96,7 +109,7 @@ export const ContentPieceCardList = ({
/>
</div>
)}
</article>
</ContentPieceCardSelectionFrame>
)
})}
</div>

View file

@ -0,0 +1,141 @@
'use client'
import { BookmarkIcon, CheckIcon, Trash2Icon, XIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useContentPieceSelection } from '@/components/campaign/content/ContentPieceSelectionProvider'
import { CampaignDeleteDialog } from '@/components/campaign/shared/CampaignDeleteDialog'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import {
CONTENT_PIECE_BATCH_DELETE_IRREVERSIBLE_MESSAGE,
CONTENT_PIECE_BATCH_DELETE_LINKS_MESSAGE,
CONTENT_PIECE_BATCH_ERROR_MESSAGE,
contentPieceBatchDeleteTitle,
contentPieceSelectionLabel,
type ContentPieceBatchAction,
} from '@/lib/contentPieceBatch'
/**
* C236 — the batch action bar (approved design scenes 2, 3b and 6): it exists
* only while something is selected, floats at the footer of the content and
* owns the three verbs. `Publicar`/`Despublicar` are the reversible kill switch
* and act on the spot, with the receipt in the list; `Apagar` always opens the
* one confirmation machine (the C222 chrome) with the link warning when the
* selection carries published pieces. While the loop runs every target blocks:
* `aria-busy` on the bar, spinner on the clicked verb, the rest disabled.
*/
export const ContentPieceSelectionBar = () => {
const router = useRouter()
const { selectedCount, selectedPublishedPaths, pendingAction, exitSelection, runBatch } =
useContentPieceSelection()
if (selectedCount === 0) return null
const pending = pendingAction !== null
const barButtonClassName = 'min-h-11 px-1 text-xs sm:px-3 sm:text-sm'
const run = async (action: ContentPieceBatchAction) => {
const result = await runBatch(action)
if (result.ok) router.refresh()
}
const description = (() => {
if (selectedPublishedPaths.length === 0) return CONTENT_PIECE_BATCH_DELETE_IRREVERSIBLE_MESSAGE
if (selectedCount > 1) return CONTENT_PIECE_BATCH_DELETE_LINKS_MESSAGE
return (
<>
O link público{' '}
<span className="font-medium text-foreground">{selectedPublishedPaths[0]}</span> deixa de
funcionar para quem já recebeu. Esta ação não pode ser desfeita.
</>
)
})()
const deleteTrigger = (
<Button type="button" variant="destructive" className={barButtonClassName} disabled={pending}>
{pendingAction === 'apagar' ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
)}
Apagar
</Button>
)
return (
<div className="sticky bottom-32 z-20 mt-4 md:bottom-4 md:mr-16">
<div
aria-busy={pending}
className="flex flex-col rounded-xl border border-border bg-card p-3 shadow-[0_10px_34px_rgb(28_25_23_/_0.16)] sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:px-3 sm:py-2.5"
>
<div className="flex items-center justify-between gap-2 sm:justify-start">
<p
role="status"
aria-live="polite"
className={
pending
? 'text-sm font-medium tabular-nums text-muted-foreground'
: 'text-sm font-medium tabular-nums'
}
>
{contentPieceSelectionLabel(selectedCount)}
</p>
<Button
type="button"
variant="ghost"
className="min-h-11 sm:hidden"
aria-label="Sair da seleção"
disabled={pending}
onClick={exitSelection}
>
<XIcon aria-hidden="true" />
</Button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 sm:mt-0 sm:flex sm:items-center">
<Button
type="button"
className={barButtonClassName}
disabled={pending}
onClick={() => void run('publicar')}
>
{pendingAction === 'publicar' ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<CheckIcon data-icon="inline-start" aria-hidden="true" />
)}
Publicar
</Button>
<Button
type="button"
variant="outline"
className={barButtonClassName}
disabled={pending}
onClick={() => void run('despublicar')}
>
{pendingAction === 'despublicar' ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<BookmarkIcon data-icon="inline-start" aria-hidden="true" />
)}
Despublicar
</Button>
<CampaignDeleteDialog
errorMessage={CONTENT_PIECE_BATCH_ERROR_MESSAGE}
title={contentPieceBatchDeleteTitle(selectedCount)}
titleClassName="border-b-0 pb-0"
contentClassName="max-w-sm"
description={description}
confirmLabel="Apagar"
trigger={deleteTrigger}
onConfirm={async () => {
const result = await runBatch('apagar')
return result.ok ? { ok: true } : { ok: false, message: result.message }
}}
/>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,156 @@
'use client'
import { SquareCheckIcon, XIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { useContentPieceSelection } from '@/components/campaign/content/ContentPieceSelectionProvider'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { cn } from '@/lib/utils'
/**
* C236 — the affordances of the selection mode (approved design scenes 1–3):
* the single entry at the top of the list, the mode badge with its exit, the
* per-row checkboxes of the table and the mobile card frame. All of them are
* client islands reading the one selection context; the table shell and the
* card bodies stay server-rendered.
*/
/** The opt-in entry ("Selecionar") and the active-mode chrome of the top of the list. */
export const ContentPieceSelectionModeControl = () => {
const { selectionMode, enterSelection, exitSelection, pendingAction } = useContentPieceSelection()
if (!selectionMode) {
return (
<div className="mt-4 flex items-center justify-end">
<Button type="button" variant="outline" className="min-h-11" onClick={enterSelection}>
<SquareCheckIcon data-icon="inline-start" aria-hidden="true" />
Selecionar
</Button>
</div>
)
}
return (
<div className="mt-4 flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
<SquareCheckIcon className="size-3.5" aria-hidden="true" />
Modo de seleção
</span>
<Button
type="button"
variant="ghost"
className="min-h-11 text-muted-foreground hover:text-foreground"
aria-label="Cancelar seleção"
disabled={pendingAction !== null}
onClick={exitSelection}
>
<XIcon data-icon="inline-start" aria-hidden="true" />
<span aria-hidden="true" className="max-sm:hidden">
Cancelar seleção
</span>
<span aria-hidden="true" className="sm:hidden">
Cancelar
</span>
</Button>
</div>
)
}
/** The header checkbox: all of the visible page, or none. */
export const ContentPieceSelectAllCheckbox = () => {
const { allSelected, selectedCount, toggleAll, pendingAction } = useContentPieceSelection()
return (
<span className="grid size-9 place-items-center">
<Checkbox
checked={allSelected ? true : selectedCount > 0 ? 'indeterminate' : false}
onCheckedChange={() => toggleAll()}
disabled={pendingAction !== null}
aria-label="Selecionar todas as peças desta página"
/>
</span>
)
}
/**
* One table-row checkbox. The wrapper carries `data-selection-state`, which the
* row's `has-[…]:bg-primary/5` class uses to tint the selected line.
*/
export const ContentPieceRowCheckbox = ({
contentPieceId,
title,
}: {
contentPieceId: number
title: string
}) => {
const { isSelected, toggle, pendingAction } = useContentPieceSelection()
const selected = isSelected(contentPieceId)
return (
<span
className="grid size-9 place-items-center"
data-selection-state={selected ? 'selected' : undefined}
>
<Checkbox
checked={selected}
onCheckedChange={() => toggle(contentPieceId)}
disabled={pendingAction !== null}
aria-label={`Selecionar ${title}`}
/>
</span>
)
}
/**
* The mobile card frame (design scene 3b): in selection mode the article gains
* the 44px touch target with the checkbox and the primary ring; outside it the
* frame renders the exact resting article, so the card look at rest does not
* depend on this island. `header` is the card's title/meta block (which sits
* beside the checkbox); children are the rest of the card body.
*/
export const ContentPieceCardSelectionFrame = ({
contentPieceId,
title,
header,
children,
}: {
contentPieceId: number
title: string
header: ReactNode
children: ReactNode
}) => {
const { selectionMode, isSelected, toggle, pendingAction } = useContentPieceSelection()
const selected = selectionMode && isSelected(contentPieceId)
if (!selectionMode) {
return (
<article className="rounded-xl border bg-card p-4">
{header}
{children}
</article>
)
}
return (
<article
className={cn(
'rounded-xl border border-border bg-card p-4',
selected && 'ring-1 ring-primary/25',
)}
>
<div className="flex items-start gap-2">
<span className="grid size-11 shrink-0 place-items-center">
<Checkbox
checked={selected}
onCheckedChange={() => toggle(contentPieceId)}
disabled={pendingAction !== null}
aria-label={`Selecionar ${title}`}
/>
</span>
<div className="min-w-0 flex-1">{header}</div>
</div>
{children}
</article>
)
}

View file

@ -0,0 +1,201 @@
'use client'
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
import type { ContentPieceBatchResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/types'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { CAMPAIGN_CONTENT_PIECE_BATCH_HREF } from '@/lib/campaignPaths'
import {
CONTENT_PIECE_BATCH_ERROR_MESSAGE,
contentPieceBatchReceiptMessage,
type ContentPieceBatchAction,
type ContentPieceBatchOutcome,
type ContentPieceSelectionRow,
} from '@/lib/contentPieceBatch'
/** What the receipt banner renders: success is `status`, a failure is `alert`. */
export type ContentPieceReceipt = {
kind: 'success' | 'failure'
message: string
}
export type ContentPieceBatchResult =
| { ok: true; outcome: ContentPieceBatchOutcome }
| { ok: false; message: string }
type ContentPieceSelectionValue = {
selectionMode: boolean
selectedCount: number
allSelected: boolean
isSelected: (id: number) => boolean
pendingAction: ContentPieceBatchAction | null
receipt: ContentPieceReceipt | null
/** `/conteudos/<slug>` of the selected published pieces (the delete warning). */
selectedPublishedPaths: readonly string[]
enterSelection: () => void
exitSelection: () => void
toggle: (id: number) => void
toggleAll: () => void
dismissReceipt: () => void
runBatch: (action: ContentPieceBatchAction) => Promise<ContentPieceBatchResult>
}
const ContentPieceSelectionContext = createContext<ContentPieceSelectionValue | null>(null)
export const useContentPieceSelection = (): ContentPieceSelectionValue => {
const value = useContext(ContentPieceSelectionContext)
if (!value) {
throw new Error('useContentPieceSelection precisa de um ContentPieceSelectionProvider.')
}
return value
}
/**
* C236 — the one selection state shared by the two bodies of the list (desktop
* table + mobile cards) and by the action bar. Client-only by design: the two
* RSC bodies and the shell stay untouched; the provider receives a serializable
* projection of the visible page and wraps them via `children`.
*
* The selection is page-scoped and dies on navigation: the page keys this
* provider by the canonical list URL, so a new page/filter remounts it. A
* refresh of the same key (post-action, status poll) preserves the state, and
* `runBatch` clears the ids on success so the receipt is about a finished
* gesture. A whole-request failure keeps the selection — nothing changed.
*/
export const ContentPieceSelectionProvider = ({
rows,
children,
}: {
rows: readonly ContentPieceSelectionRow[]
children: ReactNode
}) => {
const [selectionMode, setSelectionMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [pendingAction, setPendingAction] = useState<ContentPieceBatchAction | null>(null)
const [receipt, setReceipt] = useState<ContentPieceReceipt | null>(null)
const pageIds = useMemo(() => rows.map((row) => row.id), [rows])
const pageIdSet = useMemo(() => new Set(pageIds), [pageIds])
// The selection never outgrows the visible page: an id that left the page
// (the status poll can change the filtered rows under the same URL key) stops
// counting and never reaches the batch.
const visibleSelectedIds = useMemo(
() => selectedIds.filter((id) => pageIdSet.has(id)),
[selectedIds, pageIdSet],
)
const selectedSet = useMemo(() => new Set(visibleSelectedIds), [visibleSelectedIds])
const isSelected = useCallback((id: number) => selectedSet.has(id), [selectedSet])
const selectedPublishedPaths = useMemo(
() =>
rows.flatMap((row) =>
selectedSet.has(row.id) && row.status === 'publicado' && row.publicPath !== null
? [row.publicPath]
: [],
),
[rows, selectedSet],
)
const enterSelection = useCallback(() => {
setSelectionMode(true)
setReceipt(null)
}, [])
const exitSelection = useCallback(() => {
setSelectionMode(false)
setSelectedIds([])
}, [])
const toggle = useCallback((id: number) => {
setSelectedIds((current) =>
current.includes(id) ? current.filter((value) => value !== id) : [...current, id],
)
}, [])
const toggleAll = useCallback(() => {
setSelectedIds((current) =>
current.filter((id) => pageIdSet.has(id)).length === pageIds.length ? [] : [...pageIds],
)
}, [pageIds, pageIdSet])
const dismissReceipt = useCallback(() => setReceipt(null), [])
const runBatch = useCallback(
async (action: ContentPieceBatchAction): Promise<ContentPieceBatchResult> => {
setPendingAction(action)
try {
const { ok, payload } = await postCampaignJson<ContentPieceBatchResponse>(
CAMPAIGN_CONTENT_PIECE_BATCH_HREF,
{ action, contentPieceIds: visibleSelectedIds },
)
if (!ok || payload.status !== 'success') {
const message =
payload.status === 'error' ? payload.message : CONTENT_PIECE_BATCH_ERROR_MESSAGE
// The delete failure belongs to the confirmation dialog; the
// reversible verbs answer in the list, right where the selection is.
if (action !== 'apagar') setReceipt({ kind: 'failure', message })
return { ok: false, message }
}
setReceipt({
kind: payload.outcome.failures.length === 0 ? 'success' : 'failure',
message: contentPieceBatchReceiptMessage(payload.outcome),
})
setSelectedIds([])
return { ok: true, outcome: payload.outcome }
} catch {
if (action !== 'apagar') {
setReceipt({ kind: 'failure', message: CONTENT_PIECE_BATCH_ERROR_MESSAGE })
}
return { ok: false, message: CONTENT_PIECE_BATCH_ERROR_MESSAGE }
} finally {
setPendingAction(null)
}
},
[visibleSelectedIds],
)
const value = useMemo<ContentPieceSelectionValue>(
() => ({
selectionMode,
selectedCount: visibleSelectedIds.length,
allSelected: pageIds.length > 0 && visibleSelectedIds.length === pageIds.length,
isSelected,
pendingAction,
receipt,
selectedPublishedPaths,
enterSelection,
exitSelection,
toggle,
toggleAll,
dismissReceipt,
runBatch,
}),
[
selectionMode,
visibleSelectedIds,
pageIds,
isSelected,
pendingAction,
receipt,
selectedPublishedPaths,
enterSelection,
exitSelection,
toggle,
toggleAll,
dismissReceipt,
runBatch,
],
)
return (
<ContentPieceSelectionContext.Provider value={value}>
{/* `contents` keeps the wrapper out of the layout; `group` + the data
attribute are what the table column CSS reads to show/hide itself. */}
<div className="group contents" data-selection-mode={selectionMode ? 'true' : undefined}>
{children}
</div>
</ContentPieceSelectionContext.Provider>
)
}

View file

@ -4,6 +4,10 @@ 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 {
ContentPieceRowCheckbox,
ContentPieceSelectAllCheckbox,
} from '@/components/campaign/content/ContentPieceSelectionControls'
import {
ContentPieceProcessingBadge,
ContentPiecePublicationBadge,
@ -60,7 +64,24 @@ export const ContentPieceTable = ({
rows={rows}
rowKey={(row) => row.id}
empty={empty}
rowClassName="has-[[data-selection-state=selected]]:bg-primary/5"
columns={[
{
// C236 — the selection column is chrome of the selection mode, not a
// data column: it is mandatory (a stale cookie can never hide it) and
// the CSS shows it only while the provider is in selection mode. It is
// deliberately absent from the column picker of the page.
id: 'selection',
label: 'Seleção',
mandatory: true,
head: (
<CampaignTableHead className="hidden w-12 group-data-[selection-mode=true]:table-cell">
<ContentPieceSelectAllCheckbox />
</CampaignTableHead>
),
cellClassName: 'hidden group-data-[selection-mode=true]:table-cell',
cell: (piece) => <ContentPieceRowCheckbox contentPieceId={piece.id} title={piece.title} />,
},
{
id: 'piece',
label: 'Peça',
@ -75,7 +96,10 @@ export const ContentPieceTable = ({
<Icon className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0">
<Link href={piece.detailHref} className="font-semibold hover:underline">
<Link
href={piece.detailHref}
className="block min-w-0 font-semibold break-words line-clamp-2 hover:underline"
>
{piece.title}
</Link>
<p className="mt-1 truncate text-xs text-muted-foreground">{metaLine(piece)}</p>

View file

@ -25,9 +25,7 @@ import { deleteCampaignJson } from '@/lib/campaignJsonRequest'
*/
type CampaignDeleteDialogResponse = { status: 'success' } | { status: 'error'; message: string }
type CampaignDeleteDialogProps = {
/** The DELETE endpoint of the row (the id lives in the path). */
endpoint: string
type CampaignDeleteDialogBaseProps = {
/** 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). */
@ -41,27 +39,50 @@ type CampaignDeleteDialogProps = {
titleClassName?: string
/** Callers whose footer chrome differs from the default pass their own. */
footerClassName?: string
/** Callers whose panel width differs from the default pass their own (C236 batch). */
contentClassName?: string
}
type CampaignDeleteDialogProps = CampaignDeleteDialogBaseProps &
(
| {
/** The DELETE endpoint of the row (the id lives in the path). */
endpoint: string
}
| {
/**
* C236 — a caller that owns the whole request (the batch loop) passes
* the mutation instead of a DELETE endpoint: the machine calls it on
* confirm and keeps owning submitting/error/success.
*/
onConfirm: () => Promise<{ ok: true } | { ok: false; message: string }>
/** Runs after a successful confirm, before the refresh/redirect. */
onSuccess?: () => void
}
)
/**
* 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.
* in the dialog context, never a toast. C236 adds the `onConfirm` path: a
* caller that owns the whole request (the batch loop) passes the mutation
* itself and the machine keeps the same chrome and flow.
*/
export const CampaignDeleteDialog = ({
endpoint,
errorMessage,
trigger,
title,
description,
confirmLabel,
redirectTo,
titleClassName,
footerClassName,
}: CampaignDeleteDialogProps) => {
export const CampaignDeleteDialog = (props: CampaignDeleteDialogProps) => {
const {
errorMessage,
trigger,
title,
description,
confirmLabel,
redirectTo,
titleClassName,
footerClassName,
contentClassName,
} = props
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -71,7 +92,19 @@ export const CampaignDeleteDialog = ({
setError(null)
try {
const { ok, payload } = await deleteCampaignJson<CampaignDeleteDialogResponse>(endpoint)
if ('onConfirm' in props) {
const result = await props.onConfirm()
if (!result.ok) {
setError(result.message)
return
}
props.onSuccess?.()
if (redirectTo) router.push(redirectTo)
else router.refresh()
return
}
const { ok, payload } = await deleteCampaignJson<CampaignDeleteDialogResponse>(props.endpoint)
if (!ok || payload.status !== 'success') {
setError(payload.status === 'error' ? payload.message : errorMessage)
return
@ -88,7 +121,7 @@ export const CampaignDeleteDialog = ({
return (
<AlertDialog>
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogContent className={contentClassName}>
<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">

View file

@ -6,7 +6,7 @@ export const Spinner = ({ className, ...props }: React.ComponentProps<'svg'>) =>
data-slot="spinner"
role="status"
aria-label="Carregando"
className={cn('size-4 animate-spin', className)}
className={cn('size-4 animate-spin motion-reduce:animate-none', className)}
{...props}
/>
)

View file

@ -103,6 +103,9 @@ export const campaignContentPiecePublicationHref = (id: number): string =>
export const campaignContentPieceDeleteHref = (id: number): string =>
`${campaignContentPieceDetailHref(id)}/apagar`
/** C236 — batch gateway of the Central list (publish/unpublish/delete a selection). */
export const CAMPAIGN_CONTENT_PIECE_BATCH_HREF = `${CAMPAIGN_COMMUNICATION_CONTEUDOS}/lote` as const
const campaignRecordingDetailHref = (id: number): string =>
`${CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES}/${id}`

View file

@ -0,0 +1,79 @@
/**
* C236 — pure vocabulary and copy of the batch actions of the Central de
* Conteúdos: the three verbs, the selection counter, the delete confirmation
* and the honest receipt (affected × failures). Client-safe: the selection
* provider, the action bar, the confirmation dialog and the JSON route share
* this module.
*
* The batch is the unit gesture repeated — it never invents a rule of its own,
* so everything here is presentation of an outcome the unit actions produced.
*/
import type { ContentPieceStatus } from '@/lib/contentPiece'
export const CONTENT_PIECE_BATCH_ACTIONS = ['publicar', 'despublicar', 'apagar'] as const
export type ContentPieceBatchAction = (typeof CONTENT_PIECE_BATCH_ACTIONS)[number]
/** One item of the selection: the only fields the list projection needs. */
export type ContentPieceSelectionRow = {
id: number
status: ContentPieceStatus
/** The public `/conteudos/<slug>` when the piece has a slug; null otherwise. */
publicPath: string | null
}
type ContentPieceBatchFailure = {
contentPieceId: number
message: string
}
/** What the loop produced: how many pieces changed and which ones did not. */
export type ContentPieceBatchOutcome = {
action: ContentPieceBatchAction
affected: number
failures: ContentPieceBatchFailure[]
}
/** Transport failure of the whole request — never a per-piece failure. */
export const CONTENT_PIECE_BATCH_ERROR_MESSAGE =
'Não foi possível concluir a ação em lote. Tente novamente.'
/** The irreversible-only body of the mass delete (no published piece in the selection). */
export const CONTENT_PIECE_BATCH_DELETE_IRREVERSIBLE_MESSAGE = 'Esta ação não pode ser desfeita.'
/** The body that warns about the public links when the selection has published pieces. */
export const CONTENT_PIECE_BATCH_DELETE_LINKS_MESSAGE =
'Os links públicos das peças publicadas da seleção deixam de funcionar para quem já recebeu. Esta ação não pode ser desfeita.'
/** `3 selecionadas` / `1 selecionada` (the TourComposerForm precedent). */
export const contentPieceSelectionLabel = (count: number): string =>
`${count} ${count === 1 ? 'selecionada' : 'selecionadas'}`
const PARTICIPLES: Record<ContentPieceBatchAction, readonly [string, string]> = {
publicar: ['publicada', 'publicadas'],
despublicar: ['despublicada', 'despublicadas'],
apagar: ['apagada', 'apagadas'],
}
/**
* The honest receipt: an all-success outcome is one phrase; a partial one
* always separates affected from failures and never says "3 peças apagadas"
* when one failed.
*/
export const contentPieceBatchReceiptMessage = (outcome: ContentPieceBatchOutcome): string => {
const [singular, plural] = PARTICIPLES[outcome.action]
if (outcome.failures.length === 0) {
return `${outcome.affected} ${outcome.affected === 1 ? `peça ${singular}` : `peças ${plural}`}`
}
const total = outcome.affected + outcome.failures.length
const failed =
outcome.failures.length === 1 ? '1 falhou.' : `${outcome.failures.length} falharam.`
const noun = total === 1 ? 'peça' : 'peças'
return `${outcome.affected} de ${total} ${noun} ${total === 1 ? singular : plural}. ${failed}`
}
/** `Apagar 3 peças selecionadas?` — the singular reuses the C222 literal. */
export const contentPieceBatchDeleteTitle = (count: number): string =>
count === 1 ? 'Apagar esta peça?' : `Apagar ${count} peças selecionadas?`

View file

@ -11,6 +11,7 @@ import {
CONTENT_PIECE_TITLE_MAX_LENGTH,
CONTENT_PIECE_TYPES,
} from '@/lib/contentPiece'
import { CONTENT_PIECE_BATCH_ACTIONS } from '@/lib/contentPieceBatch'
import { contentPieceProfilePeriodError } from '@/lib/contentPieceProfileWindow'
import { trimmedNullableText } from '@/lib/schemas/primitives'
import { TRANSCRIPT_TEXT_MAX_LENGTH } from '@/lib/speechSearch'
@ -169,6 +170,18 @@ export const contentPiecePublicationRequestSchema = z.object({
published: z.boolean(),
})
/**
* C236 — apply the same verdict to a bounded set of pieces: the selection is
* one page of the list, so the ceiling mirrors the status poll (50 ids) and the
* action dedupes whatever arrives.
*/
export const contentPieceBatchRequestSchema = z.object({
action: z.enum(CONTENT_PIECE_BATCH_ACTIONS),
contentPieceIds: z.array(z.number().int().positive()).min(1).max(50),
})
export type ContentPieceBatchRequest = z.infer<typeof contentPieceBatchRequestSchema>
/** Attach the original file to a link piece. */
export const contentPieceAttachRequestSchema = z.object({
contentPieceId: z.number().int().positive(),

View file

@ -1892,5 +1892,68 @@ test.describe('communication vertical (C154/C162)', () => {
)
.toBe(true)
})
test('publishes and deletes a selection over HTTP, naming the partial failure (C236)', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('pecalote')
const published = await createPiece(campaign, { marker: `${marker}a`, status: 'publicado' })
const draft = await createPiece(campaign, { marker: `${marker}b` })
const loteUrl = '/campanha/comunicacao/conteudos/lote'
const communicator = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(communicator, communicator.password)
// Fail-closed: an advisor is denied before any piece changes.
const advisor = await campaign.fixtures.createCampaignUser('advisor')
const deniedRequest = await campaignRequest(advisor, advisor.password)
const denied = await deniedRequest.post(loteUrl, {
data: { action: 'apagar', contentPieceIds: [published.piece.id] },
})
expect(denied.status()).toBe(400)
expect(((await denied.json()) as { message: string }).message).toContain(
'não tem acesso à Central de Conteúdos',
)
// Publish the draft through the batch gateway.
const publishBatch = await request.post(loteUrl, {
data: { action: 'publicar', contentPieceIds: [draft.piece.id] },
})
expect(publishBatch.status()).toBe(200)
expect(await publishBatch.json()).toEqual({
status: 'success',
outcome: { action: 'publicar', affected: 1, failures: [] },
})
const slug = published.piece.slug!
const missingId = published.piece.id + 1_000_000
const deleteBatch = await request.post(loteUrl, {
data: { action: 'apagar', contentPieceIds: [published.piece.id, missingId] },
})
expect(deleteBatch.status()).toBe(200)
expect(await deleteBatch.json()).toEqual({
status: 'success',
outcome: {
action: 'apagar',
affected: 1,
failures: [{ contentPieceId: missingId, message: 'Peça não encontrada.' }],
},
})
// The deleted row leaves the list; the draft published above stays.
const afterList = rendered(
await (await request.get(`/campanha/comunicacao/conteudos?q=${marker}`)).text(),
)
expect(afterList).not.toContain(`Peça ${marker}a`)
expect(afterList).toContain(`Peça ${marker}b`)
const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
try {
expect((await anonymous.get(`/conteudos/${slug}`)).status()).toBe(404)
} finally {
await anonymous.dispose()
}
})
})
})

View file

@ -34,8 +34,10 @@ vi.mock('@/utilities/campaignAuth', () => ({
import { GET } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/arquivo/route'
import { POST as postProfileImportCreate } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/criar/route'
import { POST as postProfileImport } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route'
import { POST as postBatch } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/lote/route'
import {
addContentPieceByLinkForActor,
applyContentPieceBatchActionForActor,
createContentPieceFromProfilePostForActor,
deleteContentPieceForActor,
getContentPieceStatusesForActor,
@ -850,6 +852,208 @@ describe('content pieces (C211)', () => {
})
})
describe('batch actions (C236)', () => {
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('publishes and unpublishes a selection with the unit semantics preserved', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const tag = getCollectionListingTag('contentPiece')
const first = await createPiece({ title: `Lote publica ${Date.now()}a` })
const second = await createPiece({ title: `Lote publica ${Date.now()}b` })
vi.mocked(revalidateTag).mockClear()
await expect(
applyContentPieceBatchActionForActor({
action: 'publicar',
contentPieceIds: [first.piece.id, second.piece.id],
}),
).resolves.toEqual({ action: 'publicar', affected: 2, failures: [] })
expect(revalidateTag).toHaveBeenCalledWith(tag)
const publishedFirst = await payload.findByID({
collection: 'contentPiece',
id: first.piece.id,
depth: 0,
overrideAccess: true,
})
expect(publishedFirst.status).toBe('publicado')
expect(publishedFirst.slug).toBeTruthy()
await expect(
applyContentPieceBatchActionForActor({
action: 'despublicar',
contentPieceIds: [first.piece.id, second.piece.id],
}),
).resolves.toEqual({ action: 'despublicar', affected: 2, failures: [] })
const unpublished = await payload.findByID({
collection: 'contentPiece',
id: first.piece.id,
depth: 0,
overrideAccess: true,
})
// The kill switch preserves slug, publication date and the file.
expect(unpublished.status).toBe('rascunho')
expect(unpublished.slug).toBe(publishedFirst.slug)
expect(unpublished.publishedAt).toBe(publishedFirst.publishedAt)
expect(unpublished.media).toBe(first.media!.id)
})
it('deletes a selection with its media, leaves the events and dedupes repeated ids', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const first = await createPiece({ title: `Lote apaga ${Date.now()}a`, status: 'publicado' })
const second = await createPiece({ title: `Lote apaga ${Date.now()}b` })
const event = await payload.create({
collection: 'contentEvent',
data: { type: 'abertura', subjectType: 'peca', subjectId: String(first.piece.id) },
overrideAccess: true,
})
await expect(
applyContentPieceBatchActionForActor({
action: 'apagar',
contentPieceIds: [first.piece.id, first.piece.id, second.piece.id],
}),
).resolves.toEqual({ action: 'apagar', affected: 2, failures: [] })
createdPieceIds.delete(first.piece.id)
createdPieceIds.delete(second.piece.id)
await expect(
payload.findByID({ collection: 'contentPiece', id: second.piece.id, overrideAccess: true }),
).rejects.toThrow()
for (const media of [first.media, second.media]) {
if (!media) continue
expect(await mediaGone(media.id)).toBe(true)
createdMediaIds.delete(media.id)
}
const survivor = await payload.findByID({
collection: 'contentEvent',
id: event.id,
overrideAccess: true,
})
expect(survivor.subjectId).toBe(String(first.piece.id))
await payload.delete({ collection: 'contentEvent', id: event.id, overrideAccess: true })
})
it('names the failing piece and never reverts the ones that succeeded', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const existing = await createPiece({ title: `Lote parcial ${Date.now()}` })
const missingId = existing.piece.id + 100_000
await expect(
applyContentPieceBatchActionForActor({
action: 'apagar',
contentPieceIds: [existing.piece.id, missingId],
}),
).resolves.toEqual({
action: 'apagar',
affected: 1,
failures: [{ contentPieceId: missingId, message: CONTENT_PIECE_NOT_FOUND_MESSAGE }],
})
createdPieceIds.delete(existing.piece.id)
await expect(
payload.findByID({
collection: 'contentPiece',
id: existing.piece.id,
overrideAccess: true,
}),
).rejects.toThrow()
})
it('gates the batch by role: denied roles fail whole, the vertical acts', async () => {
const first = await createPiece({ title: `Lote negado ${Date.now()}a` })
const second = await createPiece({ title: `Lote negado ${Date.now()}b` })
for (const denied of [advisor, leader]) {
getCampaignUserMock.mockResolvedValue(denied)
await expect(
applyContentPieceBatchActionForActor({
action: 'apagar',
contentPieceIds: [first.piece.id, second.piece.id],
}),
).rejects.toThrow(CONTENT_PIECE_FORBIDDEN_MESSAGE)
}
for (const piece of [first.piece, second.piece]) {
await expect(
payload.findByID({ collection: 'contentPiece', id: piece.id, overrideAccess: true }),
).resolves.toMatchObject({ id: piece.id })
}
// The vertical that owns the gesture acts through the same gate.
for (const allowed of [coordinator, candidate]) {
const { piece } = await createPiece({ title: `Lote permitido ${Date.now()}` })
getCampaignUserMock.mockResolvedValue(allowed)
await expect(
applyContentPieceBatchActionForActor({
action: 'publicar',
contentPieceIds: [piece.id],
}),
).resolves.toEqual({ action: 'publicar', affected: 1, failures: [] })
}
})
it('bounds the batch with the request schema', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
await expect(
applyContentPieceBatchActionForActor({ action: 'publicar', contentPieceIds: [] }),
).rejects.toThrow()
await expect(
applyContentPieceBatchActionForActor({
action: 'publicar',
contentPieceIds: Array.from({ length: 51 }, (_, index) => index + 1),
}),
).rejects.toThrow()
})
it('answers the JSON envelope of the batch route', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const { piece } = await createPiece({ title: `Lote rota ${Date.now()}` })
const call = (body: unknown, origin = 'http://localhost') =>
postBatch(
new Request('http://localhost/campanha/comunicacao/conteudos/lote', {
method: 'POST',
headers: { 'content-type': 'application/json', origin },
body: JSON.stringify(body),
}),
)
const published = await call({ action: 'publicar', contentPieceIds: [piece.id] })
expect(published.status).toBe(200)
await expect(published.json()).resolves.toEqual({
status: 'success',
outcome: { action: 'publicar', affected: 1, failures: [] },
})
const crossOrigin = await call(
{ action: 'publicar', contentPieceIds: [piece.id] },
'https://evil.example',
)
expect(crossOrigin.status).toBe(403)
getCampaignUserMock.mockResolvedValue(advisor)
const denied = await call({ action: 'apagar', contentPieceIds: [piece.id] })
expect(denied.status).toBe(400)
await expect(denied.json()).resolves.toEqual({
status: 'error',
message: CONTENT_PIECE_FORBIDDEN_MESSAGE,
})
})
})
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

@ -171,6 +171,13 @@ describe('campaign formActions convention', () => {
'src/app/(campaign)/campanha/actions/password.ts',
'anti-enumeration + bespoke password field errors outside the shared ladder',
],
// C236 — the batch loop masks one failure per piece into the outcome's
// `failures[]` (no form state, no redirect): it is not a form-action
// ladder, and the mapping must stay the shared one, not a local copy.
[
'src/app/(campaign)/campanha/actions/contentPieces.ts',
'batch loop maps per-piece failures into the outcome, not a form-action state',
],
])
it('routes every hand-rolled mapCampaignFormActionError ladder through a shared wrapper', () => {

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
contentPieceBatchDeleteTitle,
contentPieceBatchReceiptMessage,
contentPieceSelectionLabel,
type ContentPieceBatchOutcome,
} from '@/lib/contentPieceBatch'
const outcome = (
action: ContentPieceBatchOutcome['action'],
affected: number,
failed = 0,
): ContentPieceBatchOutcome => ({
action,
affected,
failures: Array.from({ length: failed }, (_, index) => ({
contentPieceId: index + 1,
message: 'Peça não encontrada.',
})),
})
describe('contentPieceBatch (C236)', () => {
it('writes the all-success receipt in the plan literals, singular included', () => {
expect(contentPieceBatchReceiptMessage(outcome('publicar', 3))).toBe('3 peças publicadas')
expect(contentPieceBatchReceiptMessage(outcome('despublicar', 1))).toBe('1 peça despublicada')
expect(contentPieceBatchReceiptMessage(outcome('apagar', 3))).toBe('3 peças apagadas')
expect(contentPieceBatchReceiptMessage(outcome('apagar', 1))).toBe('1 peça apagada')
})
it('never calls a partial outcome a success: it separates affected from failures', () => {
expect(contentPieceBatchReceiptMessage(outcome('apagar', 2, 1))).toBe(
'2 de 3 peças apagadas. 1 falhou.',
)
expect(contentPieceBatchReceiptMessage(outcome('publicar', 1, 2))).toBe(
'1 de 3 peças publicadas. 2 falharam.',
)
expect(contentPieceBatchReceiptMessage(outcome('despublicar', 0, 1))).toBe(
'0 de 1 peça despublicada. 1 falhou.',
)
})
it('counts the selection with the TourComposerForm singular', () => {
expect(contentPieceSelectionLabel(1)).toBe('1 selecionada')
expect(contentPieceSelectionLabel(3)).toBe('3 selecionadas')
})
it('titles the mass delete and reuses the C222 singular', () => {
expect(contentPieceBatchDeleteTitle(1)).toBe('Apagar esta peça?')
expect(contentPieceBatchDeleteTitle(3)).toBe('Apagar 3 peças selecionadas?')
})
})

View file

@ -0,0 +1,270 @@
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 { ContentPieceBatchReceipt } from '@/components/campaign/content/ContentPieceBatchReceipt'
import { ContentPieceSelectionBar } from '@/components/campaign/content/ContentPieceSelectionBar'
import {
ContentPieceCardSelectionFrame,
ContentPieceRowCheckbox,
ContentPieceSelectAllCheckbox,
ContentPieceSelectionModeControl,
} from '@/components/campaign/content/ContentPieceSelectionControls'
import { ContentPieceSelectionProvider } from '@/components/campaign/content/ContentPieceSelectionProvider'
import { CAMPAIGN_CONTENT_PIECE_BATCH_HREF } from '@/lib/campaignPaths'
import type { ContentPieceSelectionRow } from '@/lib/contentPieceBatch'
const harnessRows = [
{ id: 1, title: 'Peça publicada', status: 'publicado', publicPath: '/conteudos/peca-publicada' },
{ id: 2, title: 'Peça em rascunho', status: 'rascunho', publicPath: null },
{ id: 3, title: 'Peça com falha', status: 'rascunho', publicPath: null },
] as const
const rows: ContentPieceSelectionRow[] = harnessRows.map(({ id, status, publicPath }) => ({
id,
status,
publicPath,
}))
const fetchMock = vi.fn()
const jsonResponse = (payload: unknown, ok = true) => ({ ok, json: async () => payload })
const renderHarness = () => {
render(
<ContentPieceSelectionProvider rows={rows}>
<ContentPieceSelectionModeControl />
<ContentPieceBatchReceipt />
<ContentPieceSelectAllCheckbox />
{harnessRows.map((row) => (
<ContentPieceRowCheckbox key={row.id} contentPieceId={row.id} title={row.title} />
))}
<ContentPieceSelectionBar />
</ContentPieceSelectionProvider>,
)
}
const enterSelection = () => fireEvent.click(screen.getByRole('button', { name: 'Selecionar' }))
const check = (name: string) => fireEvent.click(screen.getByRole('checkbox', { name }))
const press = (name: string) => fireEvent.click(screen.getByRole('button', { name }))
const openDeleteDialog = async () => {
press('Apagar')
return screen.findByRole('alertdialog')
}
const selectionStatus = () => screen.getByRole('status').textContent
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('ContentPieceSelection (C236)', () => {
it('enters selection mode opt-in, counts the selection and opens the bar only with one', () => {
renderHarness()
expect(screen.queryByText('Modo de seleção')).toBeNull()
enterSelection()
expect(screen.getByText('Modo de seleção')).toBeDefined()
expect(screen.queryByRole('status')).toBeNull()
check('Selecionar Peça publicada')
expect(selectionStatus()).toContain('1 selecionada')
check('Selecionar Peça em rascunho')
expect(selectionStatus()).toContain('2 selecionadas')
})
it('selects and clears the whole visible page from the header checkbox', () => {
renderHarness()
enterSelection()
fireEvent.click(
screen.getByRole('checkbox', { name: 'Selecionar todas as peças desta página' }),
)
expect(selectionStatus()).toContain('3 selecionadas')
fireEvent.click(
screen.getByRole('checkbox', { name: 'Selecionar todas as peças desta página' }),
)
expect(screen.queryByRole('status')).toBeNull()
})
it('publishes the selection in one request, shows the receipt and clears the selection', async () => {
fetchMock.mockResolvedValue(
jsonResponse({
status: 'success',
outcome: { action: 'publicar', affected: 2, failures: [] },
}),
)
renderHarness()
enterSelection()
check('Selecionar Peça em rascunho')
check('Selecionar Peça com falha')
press('Publicar')
expect(await screen.findByText('2 peças publicadas')).toBeDefined()
expect(fetchMock).toHaveBeenCalledWith(
CAMPAIGN_CONTENT_PIECE_BATCH_HREF,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ action: 'publicar', contentPieceIds: [2, 3] }),
}),
)
await waitFor(() => expect(routerMock.refresh).toHaveBeenCalled())
expect(screen.queryByText('2 selecionadas')).toBeNull()
expect(screen.getByText('Modo de seleção')).toBeDefined()
})
it('keeps the selection and answers in the list when the whole request fails', async () => {
fetchMock.mockResolvedValue({
ok: false,
json: async () => ({ status: 'error', message: 'Sessão expirada. Entre novamente.' }),
})
renderHarness()
enterSelection()
check('Selecionar Peça publicada')
press('Publicar')
expect(await screen.findByRole('alert')).toHaveProperty(
'textContent',
expect.stringContaining('Sessão expirada. Entre novamente.'),
)
expect(selectionStatus()).toContain('1 selecionada')
expect(routerMock.refresh).not.toHaveBeenCalled()
})
it('warns about the public links only when the selection carries published pieces', async () => {
renderHarness()
enterSelection()
check('Selecionar Peça publicada')
let dialog = await openDeleteDialog()
expect(within(dialog).getByText('Apagar esta peça?')).toBeDefined()
expect(within(dialog).getByText('/conteudos/peca-publicada')).toBeDefined()
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancelar' }))
check('Selecionar Peça em rascunho')
dialog = await openDeleteDialog()
expect(within(dialog).getByText('Apagar 2 peças selecionadas?')).toBeDefined()
expect(within(dialog).getByText(/deixam de funcionar para quem já recebeu/)).toBeDefined()
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancelar' }))
check('Selecionar Peça publicada')
check('Selecionar Peça com falha')
dialog = await openDeleteDialog()
expect(within(dialog).getByText('Apagar 2 peças selecionadas?')).toBeDefined()
expect(within(dialog).getByText('Esta ação não pode ser desfeita.')).toBeDefined()
})
it('deletes the selection after the confirmation and names the partial failure', async () => {
fetchMock.mockResolvedValue(
jsonResponse({
status: 'success',
outcome: {
action: 'apagar',
affected: 2,
failures: [{ contentPieceId: 3, message: 'Peça não encontrada.' }],
},
}),
)
renderHarness()
enterSelection()
check('Selecionar Peça em rascunho')
check('Selecionar Peça com falha')
const dialog = await openDeleteDialog()
fireEvent.click(within(dialog).getByRole('button', { name: 'Apagar' }))
expect(await screen.findByRole('alert')).toHaveProperty(
'textContent',
expect.stringContaining('2 de 3 peças apagadas. 1 falhou.'),
)
expect(fetchMock).toHaveBeenCalledWith(
CAMPAIGN_CONTENT_PIECE_BATCH_HREF,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ action: 'apagar', contentPieceIds: [2, 3] }),
}),
)
await waitFor(() => expect(routerMock.refresh).toHaveBeenCalled())
})
it('keeps the delete failure inside the dialog, with the selection intact', async () => {
fetchMock.mockResolvedValue(
jsonResponse({ status: 'error', message: 'Peça não encontrada.' }, false),
)
renderHarness()
enterSelection()
check('Selecionar Peça publicada')
check('Selecionar Peça em rascunho')
const dialog = await openDeleteDialog()
fireEvent.click(within(dialog).getByRole('button', { name: 'Apagar' }))
expect(await within(dialog).findByText('Peça não encontrada.')).toBeDefined()
expect(screen.queryByText('2 de 3 peças apagadas. 1 falhou.')).toBeNull()
expect(within(dialog).getByText('Apagar 2 peças selecionadas?')).toBeDefined()
expect(routerMock.refresh).not.toHaveBeenCalled()
})
it('leaves the selection mode from the top control', () => {
renderHarness()
enterSelection()
check('Selecionar Peça publicada')
fireEvent.click(screen.getByRole('button', { name: 'Cancelar seleção' }))
expect(screen.queryByText('Modo de seleção')).toBeNull()
expect(screen.queryByRole('status')).toBeNull()
expect(screen.getByRole('button', { name: 'Selecionar' })).toBeDefined()
})
it('selects from the mobile card frame with the one shared state', () => {
render(
<ContentPieceSelectionProvider rows={rows}>
<ContentPieceSelectionModeControl />
<ContentPieceCardSelectionFrame
contentPieceId={1}
title="Peça publicada"
header={<p>Peça publicada</p>}
>
<p>Corpo do card</p>
</ContentPieceCardSelectionFrame>
<ContentPieceSelectionBar />
</ContentPieceSelectionProvider>,
)
// At rest the frame is the exact card, with no checkbox to scan past.
expect(screen.queryByRole('checkbox')).toBeNull()
expect(screen.getByText('Corpo do card')).toBeDefined()
enterSelection()
check('Selecionar Peça publicada')
expect(selectionStatus()).toContain('1 selecionada')
})
})