diff --git a/docs/changelog/2026-08-24-b155-plus.md b/docs/changelog/2026-08-24-b155-plus.md new file mode 100644 index 00000000..1064e9f1 --- /dev/null +++ b/docs/changelog/2026-08-24-b155-plus.md @@ -0,0 +1 @@ +**Recently resolved (2026-08-24):** **B155+** (Issue #368) — padrão de escrita de célula: migra os quick-edits restantes de form action para rota JSON. **F1 (sinal de municípios) morreu antes do lote** — C87 (2026-08-07) trocou o quick-edit por form multi-campo (`MunicipalityListUpdateControl`), que per a regra B155 corretamente permanece em server action; registrado como "já resolvido" no plano de intenção. **F2+F3 entregues:** `POST /campanha/liderancas/state-deputies` e `/campanha/liderancas/municipalities` via `campaignJsonMutationRoute` (schemas reusados `leadershipStateDeputyMembershipSchema`/`leadershipMunicipalitiesMembershipSchema`; allowlists floor/cap/scope/cap-dobradinhas; revalidate herdado das actions record — contrato B34/B31 preservado, a lista onde o chip está não é revalidada). Call sites trocados por wrappers `'use client'` de mesma assinatura `(state, formData) → postCampaignJson` (`LeadershipStateDeputiesColumnCell`, `LeadershipMunicipalitiesColumnCell` em `src/components/campaign/leadership/`), shared cells intocadas (multi-consumidor: dobradinhas/assessores/pessoas). **B36 incluído de graça** (coluna "Lideranças" de `/campanha/dobradinhas` usa a mesma action record da rota F2 — 3 form actions removidas no total). **Deferido com gatilho:** chips B37/B156 de `/campanha/dobradinhas` (domínio stateDeputy) e inline-edits por campo B153/B163/C129 (contrato de célula próprio) — rotas F2/F3 já nascem prontas para os dois lados. Guards atualizados: whitelist do `sharedSheetHostConventions` (+2 wrappers, padrão `PeopleMunicipalityCell`), prewarm e2e (+2 rotas), e2e B34 aponta para `/campanha/liderancas/municipalities`. Sem migration/access/Consent/UI/schema (Impeccable A). diff --git a/docs/plans/escala-dry-pos-b155-padrao-rota-impl.md b/docs/plans/escala-dry-pos-b155-padrao-rota-impl.md new file mode 100644 index 00000000..e3d82d62 --- /dev/null +++ b/docs/plans/escala-dry-pos-b155-padrao-rota-impl.md @@ -0,0 +1,163 @@ +# Impl: Escala/DRY pós-B155 — padrão de escrita de célula: quick-edits via form action → rota JSON + +Status: rascunho +Atualizado em: 2026-08-24 +Issue: #368 +Intenção: docs/plans/escala-dry-pos-b155-padrao-rota.md +Appetite restante: herdado (~0,5–1 dia eng; sem migration) + +## Leitura da intenção + +- **Outcome:** As anomalias de quick-edit de célula ainda via form action migram para o padrão `campaignJsonMutationRoute` + `postCampaignJson` (mesmo transporte das 8+ rotas irmãs), reusando as actions record sem mudança de domínio, transporte apenas (Impeccable A), sem migration. +- **O que NÃO negociar:** `campaignJsonMutationRoute` é obrigatório (o sweep de `codebaseConventions.unit.spec.ts` recusa POST nu); as actions record existentes são reusadas sem mudança de acesso/revalidação; o contrato de revalidação B34/B31 é preservado (a lista `/campanha/liderancas` onde o chip está NÃO é revalidada — `leadership.ts:630-639`); copy pt-BR, identificadores em inglês. +- **O que reavaliar** (estado do main em 2026-08-24, 798 commits depois da intenção): + - **F1 está MORTA — fora do lote, registrar como "já resolvido no simplify/fora".** O commit C87 (`212a1f24`, 2026-08-07, "atualização unificada (texto + polaridade + urgente)") removeu `MunicipalityListSignalControl` e `createMunicipalityListSignalFormAction` (grep em `src/`: zero ocorrências de ambos) e os substituiu pelo form multi-campo unificado `MunicipalityListUpdateControl` (`src/components/campaign/municipality/MunicipalityListUpdateControl.tsx` — `useActionState` + `startTransition` explícito em `:60`/`:90`, **sem** `useCampaignCellAutosave`) com `createMunicipalityListUpdateFormAction` (`municipalityStaffFormActions.ts:103`, wired em `municipios/page.tsx:193` como `signalFormAction`). Per a regra travada B155 (form multi-campo → server action), essa superfície CORRETAMENTE permanece em server action. Nada a migrar. `municipio/[slug]/v2/formActions.ts:3-9` já é shim de `createMunicipalityListUpdateFormAction` — nenhum símbolo morto restante. Atualizar o plano de intenção: as fases 1 e 3 (F1 + menção ao sinal em `politica de rotas`) saem; restam F2 + F3. + - **F2 e F3 continuam vivas exatamente como escopadas** (ver "Abordagem recomendada"). A página de lideranças agora é lista virtualizada (B161, `CampaignListResults`) — as células passam por `leadershipColumns` em `liderancas/page.tsx`; os call sites dos chips continuam `:232-243` (Municípios) e `:257-270` (Dobradinhas). + - **`useCampaignCellFailureChannel` não é necessário neste lote:** o contrato confirmado (`src/components/campaign/shared/useCampaignCellFailureChannel.ts:19` — `{ errorMessage, setErrorMessage, reportFailure, noteOpenChange }`) serve controles de lista auto-save (F1 morto). Nos F2/F3 o feedback já vive na shared cell (`RelationChipCell`/`RelationOptionCell` — Alert inline + toast de undo C128), que consome o retorno `CampaignFormActionState` do `commitAction`/`membershipAction`. O shim só traduz o transporte. + - **Schemas já exportados:** `leadershipStateDeputyMembershipSchema` (`src/lib/schemas/leadership.ts:148`) e `leadershipMunicipalitiesMembershipSchema` (`:164`, com `.min(1).max(MAX_LEADERSHIP_MUNICIPALITIES)` + dedupe) são exatamente os bodies JSON das rotas F2/F3 — reusar, não colar. + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph F2["F2 — Dobradinhas (coluna de /campanha/liderancas)"] + C2[LeadershipStateDeputyRelationCell] -->|membershipAction = shim route-backed| S2[shim 'use client' LeadershipStateDeputiesColumnCell] + S2 -->|postCampaignJson| R2[POST /campanha/liderancas/state-deputies] + R2 -->|campaignJsonMutationRoute + leadershipStateDeputyMembershipSchema| A2[setLeadershipStateDeputyMembership] + end + subgraph F3["F3 — Municípios (coluna de /campanha/liderancas)"] + C3[MunicipalityPortfolioCell] -->|commitAction = shim route-backed| S3[shim 'use client' LeadershipMunicipalitiesColumnCell] + S3 -->|postCampaignJson| R3[POST /campanha/liderancas/municipalities] + R3 -->|campaignJsonMutationRoute + leadershipMunicipalitiesMembershipSchema| A3[setLeadershipMunicipalitiesMembership] + end + A2 -->|revalidateLeadershipStateDeputyPaths
lista NÃO revalidada| RV2 + A3 -->|revalidateLeadershipMunicipalityPaths
lista NÃO revalidada \(B34/B31\)| RV3 +``` + +**Opções consideradas:** A (refatorar as shared cells para transporte rota interno) | B (wrappers de coluna `'use client'` no call site da página, com shim route-backed de mesma assinatura) | C (prop `transport: 'form' | 'route'` nas shared cells) | D (shim solto sem wrapper, passado direto da página) + +**Recomendação:** **B** — porque a página de lideranças é server component (não pode carregar `postCampaignJson`/hooks clientes), as shared cells têm múltiplos consumidores (dobradinhas B36/B37, assessores B156, pessoas C128) que não entram no lote, e `RelationChipCell` chama `commitAction({}, formData)` como função async regular dentro de `startTransition` (`RelationChipCell.tsx:555-558`) — qualquer função com a assinatura `(state: CampaignFormActionState, formData: FormData) => Promise` funciona, sem `useActionState`. O wrapper `'use client'` encapsula o shim e repassa as props da página para a shared cell intocada. É exatamente a arquitetura da tentativa anterior (branch `origin/agent/368-padrao-rota-celula`, `e494fdca` — wrappers `LeadershipMunicipalitiesColumnCell`/`LeadershipStateDeputiesColumnCell` em `src/components/campaign/leadership/`), validada e reutilizada, menos o F1. + +**Rejeitadas:** + +- **A:** refatorar `RelationChipCell`/`RelationOptionCell` para `postCampaignJson` interno → quebra ou exige refatorar os consumidores fora do lote (dobradinhas, assessores, pessoas) e viola "transporte apenas". +- **C:** prop de transporte nas shared cells → os dois modos coexistem para 2 call sites; o destino é toda célula em rota — não faz sentido manter o modo form. +- **D:** passar o shim como import direto na página → página é server component; funções clientes não podem ser definidas/carregadas lá. O wrapper é o envelope mínimo que torna o shim legal. + +### Componentes / mudanças + +- **`POST /campanha/liderancas/state-deputies/route.ts`** (novo, `src/app/(campaign)/campanha/(app)/liderancas/state-deputies/route.ts`): `export const POST = campaignJsonMutationRoute(` (literal exigido pelo sweep) com `bodySchema = leadershipStateDeputyMembershipSchema` (reuso direto de `src/lib/schemas/leadership.ts:148`), `safeMessages: [...leadershipStaffEditSafeMessages, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE]`, `genericMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.'` (cópia da form action). Handler: `await setLeadershipStateDeputyMembership(body)` → `NextResponse.json({ status: 'success', message: 'Dobradinhas atualizadas.' })`. `types.ts` colado: `LeadershipStateDeputiesToggleResponse = { status: 'success'; message: string } | { status: 'error'; message: string }` (padrão `liderancas/support-status/types.ts`). `export const dynamic = 'force-dynamic'` (padrão das irmãs). +- **`POST /campanha/liderancas/municipalities/route.ts`** (novo, `.../liderancas/municipalities/route.ts`): `bodySchema = leadershipMunicipalitiesMembershipSchema` (o cap já é o ceiling do schema; o floor de 1 é a action record contra o array resultante), `safeMessages: [...leadershipStaffEditSafeMessages, LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE]`, `genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.'`. Handler: `await setLeadershipMunicipalitiesMembership(body)` → `{ status: 'success', message: 'Municípios atualizados.' }`. `types.ts` + `force-dynamic` iguais. **Nota de body:** a shared cell envia `ownerId` no FormData; o shim converte para `leadershipId` no JSON (a action record exige `leadershipId`). +- **`LeadershipStateDeputiesColumnCell`** (novo, `src/components/campaign/leadership/LeadershipStateDeputiesColumnCell.tsx`, `'use client'`): wrapper que recebe as props da página, monta com `useCallback` o shim `(state, formData) => Promise` — lê `leadershipId`/`stateDeputyId`/`assigned` do FormData (parse idêntico ao de `liderancas/formActions.ts:92-95`, hoje `requiredRelationshipFormValue`/`requiredFormBoolean`) e chama `postCampaignJson('/campanha/liderancas/state-deputies', { leadershipId, stateDeputyId, assigned })`; `ok` → `{ status: 'success', message: payload.message }`; senão → `{ message: payload.message }` (sem `status` — o caminho de erro da shared cell reverter o otimismo). Renderiza `LeadershipStateDeputyRelationCell` com `membershipAction={shim}` e o restante das props repassadas. +- **`LeadershipMunicipalitiesColumnCell`** (novo, `src/components/campaign/leadership/LeadershipMunicipalitiesColumnCell.tsx`, `'use client'`): idem para `MunicipalityPortfolioCell` com `commitAction={shim}`; o shim lê `ownerId` (campo que a cell envia — `MunicipalityPortfolioCell.tsx:190`), `municipalityIds` repetidos e `assigned`, e chama `postCampaignJson('/campanha/liderancas/municipalities', { leadershipId: ownerId, municipalityIds, assigned })`. +- **`liderancas/page.tsx`** (editado): troca `MunicipalityPortfolioCell` (`:232-243`) por `LeadershipMunicipalitiesColumnCell` (remove a prop `commitAction`) e `LeadershipStateDeputyRelationCell` (`:257-270`) por `LeadershipStateDeputiesColumnCell` (remove `membershipAction`); remove os imports de `./formActions` (`:83-84`). O restante (filter head, drawerTitle, min/max, options) permanece. +- **`liderancas/formActions.ts`** (editado): remove `setLeadershipStateDeputyMembershipFormAction` (`:86-101`) e `setLeadershipMunicipalitiesFormAction` (`:109-124`). `updateLeadershipContactFormAction` (B153, `:42-83`) PERMANECE — fora do lote (ver decisão de espelhos). Limpar imports órfãos (`requiredFormBoolean`, `repeatedRelationshipFormValues`, `LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`, floor/cap/scope) e reenxugar o `safeMessages` local para o de B153. +- **`tests/e2e/setup.e2e.spec.ts`** (editado): adicionar `'/campanha/liderancas/state-deputies'` e `'/campanha/liderancas/municipalities'` ao prewarm de POSTs (`:73-93`). +- **`tests/e2e/campaignLeaderships.e2e.spec.ts`** (editado): `:102` — `expectPostResponse(page, '/campanha/liderancas')` → `expectPostResponse(page, '/campanha/liderancas/municipalities')`; atualizar o comentário acima (não há mais server action POSTando na URL da página para chips — o POST agora é a rota JSON). +- **`tests/e2e/campaignMunicipalities.e2e.spec.ts`**: **sem mudança** — verificado: `:319` (B157) e `:470-522` (B176) cobrem a coluna "Dobradinhas" da página de MUNICÍPIOS (`setMunicipalityStateDeputiesFormAction`/`createMunicipalityStateDeputyFormAction`, wired em `municipios/page.tsx:190-191`) — superfície fora do lote, segue em form action. +- **`tests/unit/campaignCellEditOverlay.unit.spec.ts`**: **sem mudança obrigatória** — importa `MunicipalityPortfolioCell` (shared, intocada); as células de liderança não estão nos `overlayCases`. Conferir na fase 2 se vale adicionar os wrappers (opcional, sem pressão de teste). +- **`tests/unit/codebaseConventions.unit.spec.ts`**: sem mudança — sweep automático (`:227-244`); as rotas novas nascem com o literal `export const POST = campaignJsonMutationRoute(`. +- **Migration:** nenhuma. **Access / Consent:** sem mudança — `getCampaignActionContext` (`src/utilities/campaignActionContext.ts:88`) lê a sessão via cookie e lança `CAMPAIGN_AUTH_REQUIRED_MESSAGE`/`CAMPAIGN_SESSION_EXPIRED_MESSAGE`, que o wrapper mapeia para 401 (`campaignJsonMutationRoute.ts:55-73`); as actions record já usam `overrideAccess: false`. A rota não duplica lógica de access além do que o wrapper exige (same-origin 403 + safe-messages). **UI:** Impeccable A — transporte apenas; nenhuma shape/craft/critique. + +### Dados → forma + +Não se aplica (refactor de transporte; a apresentação dos chips/undo/toast é das shared cells e não muda). + +## Decisões de engenharia + +### F1: registrar como já resolvido + +**Situação:** A intenção cataloga 3 anomalias; o main só tem 2. C87 (`212a1f24`) trocou o sinal por form multi-campo unificado, e a regra B155 manda form multi-campo para server action — a superfície atual está CORRETA. + +**Decisão:** F1 entra na seção "Já resolvido no simplify (não reabrir)" do plano de intenção — zero trabalho neste lote. A seção de fases da intenção ("F1 primeiro — ROI mais alto") é reescrita para F2+F3. + +**Rejeitadas:** migrar `createMunicipalityListUpdateFormAction` para rota mesmo assim (violaria a regra travada B155 — form multi-campo, submit explícito, campos interdependentes); reabrir o quick-edit de sinal como célula auto-save (C87 foi deliberação de produto fechada). + +### Shim route-backed vs refatoração interna da shared cell + +**Opções:** A (wrappers de coluna `'use client'` com shim `(state, formData) => Promise` chamando `postCampaignJson`) | B (refatorar `RelationChipCell`/`RelationOptionCell` para rota interno) | C (prop `transport`) + +**Recomendação:** A — o contrato de chamada das shared cells já é uma função async regular (`RelationChipCell.tsx:555-558`), o wrapper é o envelope mínimo que legaliza um shim client-side numa página server, e as shared cells (multi-consumidor) ficam intocadas — o mesmo desenho da tentativa anterior, reaproveitado integralmente para F2/F3. + +**Rejeitadas:** B porque exige tocar consumidores fora do lote (dobradinhas, assessores, pessoas) e muda transporte onde o lote não migra; C porque mantém dois transportes vivos nas cells para 2 call sites — o destino é rota para tudo. + +### Rotas reusam os schemas exportados vs colar schema local + +**Opções:** A (reusar `leadershipStateDeputyMembershipSchema`/`leadershipMunicipalitiesMembershipSchema` de `src/lib/schemas/leadership.ts`) | B (colar `z.strictObject({...})` com `positiveRelationshipId` no route.ts, padrão das rotas irmãs mais antigas) + +**Recomendação:** A — os dois schemas são exatamente os bodies JSON (o de municípios já tem `.min(1).max(30)` + dedupe, e o cap como ceiling do próprio schema, comentado em `schemas/leadership.ts:166-171`); o precedente próximo é `political-trend/route.ts:16-18`, que reusa `municipalityPoliticalTrendSchema`. Colar duplicaria a validação em dois lugares. + +**Rejeitadas:** B porque cria dois schemas com a mesma forma em módulos vizinhos — DRY violado sem volatilidade que justifique. + +### Destino das quick-edits espelho (fora deste lote — decidir e registrar) + +**Situação:** `setLeadershipStateDeputyMembershipFormAction` em `dobradinhas/formActions.ts:129-144` (B36, coluna "Lideranças" de `/campanha/dobradinhas`, wired em `dobradinhas/page.tsx:266` — mesma action record do F2), `setStateDeputyMunicipalitiesFormAction` (B37, `dobradinhas/formActions.ts:170-174`), `setStateDeputyAdvisorMembershipFormAction` (B156, `dobradinhas/formActions.ts:147-162`), `updateStateDeputyPartyFormAction` (B163), `updateStateDeputyBallotNameFormAction` (C129, `dobradinhas/formActions.ts:115-126`), `updateLeadershipContactFormAction` (B153, `liderancas/formActions.ts:42-83`). + +**Decisão:** + +- **B36 / B37 / B156 → defer com gatilho** (não entram no lote para respeitar o appetite ~0,5–1 dia): gatilho = próximo lote de células em `/campanha/dobradinhas` — as rotas F2/F3 nascem prontas para os dois lados (mesma action record, mesmo body) e B156 tem a allowlist pronta (`LEADERSHIP_ADVISORS_CAP_MESSAGE`/`LEADERSHIP_ADVISORS_UNRESTRICTED_MESSAGE`, `schemas/leadership.ts:41-44`). +- **B153 / B163 / C129 → defer com gatilho** de um lote dedicado a **inline-edits por campo** (`CampaignInlineEditableCell`, `liderancas/page.tsx:197`): não são chips/toggles de relação — o popover de edição de campo único tem outro contrato de chamada e precisa de tratamento próprio (rota por campo + cell editando via `postCampaignJson`). Fora deste lote, NÃO descartado permanentemente. +- **Rejeitada:** "fora de escopo permanente" para qualquer um deles — a regra B155 (célula → rota) não distingue chip de inline-edit; descartar criaria um padrão duplo sem evidência de produto. O que fica permanente fora é o ladder de forms (`runCampaignFormAction` para forms multi-campo — B155), não essas células. + +### Revalidação após mutation + +**Situação:** `setLeadershipStateDeputyMembership` (`leadership.ts:615-627`) revalida `revalidateLeadershipStateDeputyPaths` (`:508`); `setLeadershipMunicipalitiesMembership` (`:759-773`) revalida `revalidateLeadershipMunicipalityPaths` (`:640-648`), com a lista `/campanha/liderancas` deliberadamente excluída (`:630-639` — contrato B34/B31). + +**Decisão:** as rotas herdam isso automaticamente ao chamar as actions record — zero linhas de revalidação nas rotas, zero mudança de contrato. Adicionar a lista ao revalidate seria mudança de comportamento, não de transporte (mesma posição da tentativa anterior). + +## Fases verificáveis + +### Fase 1 — Tracer: as 2 rotas + specs + +1. Criar `liderancas/state-deputies/route.ts` + `types.ts` e `liderancas/municipalities/route.ts` + `types.ts` (schema reusado, safe-messages, `force-dynamic`). +2. Adicionar as 2 rotas ao prewarm POST de `tests/e2e/setup.e2e.spec.ts:73-93`. +3. `campaignLeaderships.e2e.spec.ts:102` → `expectPostResponse(page, '/campanha/liderancas/municipalities')`. +4. Verificar a rota no navegador (`pnpm dev` + sessão de campanha): POST `{ leadershipId, stateDeputyId, assigned: false }` e `{ leadershipId, municipalityIds: [..], assigned: true }` respondem `{ status: 'success', ... }`; body malformado → 400 com `genericMessage`; sem sessão → 401. +5. Rodar o spec e2e de lideranças. + +### Fase 2 — Shims e remoção das form actions + +1. Criar `LeadershipStateDeputiesColumnCell` e `LeadershipMunicipalitiesColumnCell` em `src/components/campaign/leadership/` (shims route-backed com a mesma assinatura; parse do FormData copiado de `liderancas/formActions.ts`). +2. Trocar os call sites em `liderancas/page.tsx:232-243` e `:257-270`; remover imports de `./formActions`. +3. Remover as 2 form actions de `liderancas/formActions.ts`; limpar imports órfãos. +4. `pnpm exec knip` (exports órfãos), `tsc --noEmit`, conferir `tests/unit/campaignCellEditOverlay.unit.spec.ts` (sem mudança esperada). + +### Fase 3 — Gates + +1. `pnpm gate:fast` (cascade: lint/format/typecheck/knip/cycles/unit/int). +2. e2e afetados: `campaignLeaderships` (B32/B34) e `campaignMunicipalities` (regressão B157/B176 — devem passar intocados). +3. `pnpm check:cycles`; `pnpm push` + PR + auto-merge. + +## Rabbit holes / Não escopo (engenharia) + +- **Generalizar shared cells / fundir espelhos** durante a migração — os 2+2 controles seguem espelhos; refactor de transporte apenas. +- **Migrar B36/B37/B156/B163/C129/B153** — defer com gatilho registrado acima; não entram nas fases. +- **`MunicipalityListUpdateControl` / `createMunicipalityListUpdateFormAction` (ex-F1)** — form multi-campo; fica em server action por regra B155. +- **Coluna "Dobradinhas" da página de municípios** (`setMunicipalityStateDeputiesFormAction`, `municipios/page.tsx:190-191`) — espelho de relação, mas outra superfície; não tocada. +- **Mudar o contrato de revalidação B34/B31** (adicionar `/campanha/liderancas` ao revalidate) — comportamento, não transporte. +- **`useCampaignCellFailureChannel` nos shims** — o feedback já é da shared cell; canal duplo seria camada sem volatilidade. +- **Fundir com UX / copy** — Impeccable A; nenhum achado de critique pendente nesta superfície. + +## Riscos e mitigação + +- **Shim diverge do contrato do FormData da shared cell:** `MunicipalityPortfolioCell` envia `ownerId` + `municipalityIds` repetidos + `assigned` (`MunicipalityPortfolioCell.tsx:187-199`); `LeadershipStateDeputyRelationCell` envia `leadershipId` + `stateDeputyId` + `assigned` (`:98-113`). **Mitigação:** o shim copia o parse das form actions atuais (`liderancas/formActions.ts:92-95` e `:115-118` — a fonte da verdade do formato), com `Number()` nos ids e `assigned` string→boolean; o e2e B34 (rota apontada) cobre o fluxo feliz e o undo C128 re-chama o shim pelo mesmo caminho. +- **Erro fora de 200 (auth 401 / malformed 400 / safe-message 400):** `postCampaignJson` retorna `{ ok, payload }` (`campaignJsonRequest.ts:12-26`) — o shim propaga `payload.message`; `ok` separado do status porque há rotas que respondem 409 com body real. **Mitigação:** o shim só olha `ok` + `payload.message`, nunca o status. +- **Página server component:** shim não pode viver em `liderancas/page.tsx`. **Mitigação:** wrappers `'use client'` (opção B) — o motivo estrutural da escolha. +- **Sweep `codebaseConventions` recusa POST nu:** as rotas devem exportar o literal `export const POST = campaignJsonMutationRoute(` (`codebaseConventions.unit.spec.ts:227-244`). **Mitigação:** nascer no padrão, como as irmãs. +- **Knip/imports órfãos após remover as form actions:** `requiredFormBoolean`, `repeatedRelationshipFormValues` e as constantes floor/cap/scope perdem uso em `liderancas/formActions.ts`. **Mitigação:** limpar no mesmo commit; `pnpm exec knip` na fase 2. +- **`safeMessages` errados nas rotas:** mensagens de erro das actions record são match por EXACT string (`campaignJsonMutationRoute.ts:51-74`) — allowlist faltando colapsa em `genericMessage`. **Mitigação:** espelhar a lista da form action atual (que já era a allowlist do `runCampaignFormAction`), sem `LEADERSHIP_INVALID_CONTACT_MESSAGE`/`INVALID_FIELD_MESSAGE` (só pertencem ao B153). +- **e2e flakiness da migração:** o B34 hoje espera POST na URL da página (`campaignLeaderships.e2e.spec.ts:102`) — se não migrar junto com o código, o teste fica pendurado até timeout. **Mitigação:** spec migrado na mesma fase do código (fase 1 antecipa a rota, fase 2 o call site — rodar o spec nas duas). +- **B161 (lista virtualizada):** wrappers são componentes clientes comuns — compatíveis com `CampaignListResults`; nada de portal/scroll novo. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto — F2+F3 migrados para rota; F1 registrado como já resolvido no simplify/fora (C87) +- [ ] Invariantes AGENTS/engineering-standards — `campaignJsonMutationRoute` obrigatório; actions record reusadas com `overrideAccess: false` e transação; contrato de revalidação B34/B31 preservado; copy pt-BR, identificadores em inglês +- [ ] Testes de domínio previstos — e2e B34 apontando para `/campanha/liderancas/municipalities`; prewarm com as 2 rotas novas; regressão B157/B176 intocada +- [ ] `codebaseConventions` — zero offenders no sweep automático +- [ ] `knip` — zero exports órfãos; `pnpm check:cycles` — zero ciclos +- [ ] Sem migration, sem mudança de acesso/consent, sem mudança de UI (Impeccable A) + +## Self-score decision-quality + +**5/5** — (1) decisões caras têm rejeitadas (shim vs refatorar cells vs prop transport; rotas reusam schema vs colam; destino dos espelhos decidido com alternativa rejeitada); (2) cabe no appetite herdado (~0,5–1 dia: 2 rotas + 2 wrappers + 2 remoções + 2 specs — a tentativa anterior provou o tamanho); (3) rabbit holes nomeados (espelhos, ex-F1, coluna de municípios, revalidação, canal de falha duplo); (4) depth check: reusa `campaignJsonMutationRoute`, `postCampaignJson`, actions record, schemas exportados, `leadershipStaffEditSafeMessages`, shared cells, prewarm existente — nenhum módulo novo além dos wrappers mínimos; (5) intenção satisfeita: transporte apenas, sem reescrever o outcome — com a divergência F1 explicitamente reconciliada no plano de intenção. diff --git a/docs/plans/escala-dry-pos-b155-padrao-rota.md b/docs/plans/escala-dry-pos-b155-padrao-rota.md index 0c725306..d5a2fbf5 100644 --- a/docs/plans/escala-dry-pos-b155-padrao-rota.md +++ b/docs/plans/escala-dry-pos-b155-padrao-rota.md @@ -1,7 +1,7 @@ # Escala/DRY pós-B155 — padrão de escrita de célula: quick-edits via form action → rota JSON Status: rascunho -Atualizado em: 2026-08-04 +Atualizado em: 2026-08-24 (escopo revisado — F1 resolvida por C87; B36 entra) Issue: #368 Prioridade: P2 Item do roadmap: débito da entrega B155 (#359) — regra travada com o humano em 2026-08-04: **célula → rota, form → server action** @@ -18,36 +18,45 @@ multi-campo** usam **server action** (`runCampaignFormAction`). O padrão rota `codebaseConventions.unit.spec.ts` recusa `POST` nu — com 8+ endpoints sob `(app)/municipios` + `liderancas/support-status`. -Restam **3 quick-edits de célula** que ainda passam por form action (as anomalias): +Restam quick-edits de célula que ainda passam por form action (as anomalias): -1. **Sinal na lista de municípios** — `MunicipalityListSignalControl` recebe - `signalFormAction` = `createMunicipalityListSignalFormAction` (debounce/abort via - `useCampaignCellAutosave`), mesma superfície que já tem advisors/leaderships/level/trend - por rota. -2. **Coluna "Dobradinhas" de `/campanha/liderancas` (B31)** — `setLeadershipStateDeputyMembershipFormAction`. +1. ~~**Sinal na lista de municípios** — ~~ **RESOLVIDO por C87 (2026-08-07):** o quick-edit de sinal + (`MunicipalityListSignalControl`/`createMunicipalityListSignalFormAction`) foi substituído pelo + form multi-campo unificado `MunicipalityListUpdateControl` (body/polaridade/urgente) — per a + regra B155, form multi-campo permanece em server action. Nada a migrar. +2. **Coluna "Dobradinhas" de `/campanha/liderancas` (B31)** — `setLeadershipStateDeputyMembershipFormAction` + (mesma action record também na coluna "Lideranças" de `/campanha/dobradinhas`, B36 — a rota F2 + serve os dois lados). 3. **Coluna "Municípios" de `/campanha/liderancas` (B34)** — `setLeadershipMunicipalitiesMembershipFormAction` (delta por chip e batch território/ZE). +**Deferido (fora deste lote):** colunas de `/campanha/dobradinhas` B37 (Municípios, +`setStateDeputyMunicipalitiesBatch`) e B156 (Assessores), inline-edits por campo B153 (contatos de +liderança), B163 (partido) e C129 (nome de legenda) — mesmas células-espelho em outros domínios; +gatilho registrado no `*-impl.md`. + ## Escopo -Migrar os 3 para o padrão rota, sem tocar comportamento: +Migrar as anomalias vivas para o padrão rota, sem tocar comportamento: -- **F1 — sinal de municípios:** nova rota `POST /campanha/municipios/signal` (ou reusar o - contrato do `political-trend`) via `campaignJsonMutationRoute`; o controle passa a chamar - `postCampaignJson` mantendo debounce/abort; `createMunicipalityListSignalFormAction` vira - wrapper interno ou é eliminada. +- **F1 — ~~sinal de municípios~~** **não existe mais** — C87 substituiu o quick-edit por form + multi-campo (fica em server action por regra B155). Registrado como "já resolvido", não reabrir. - **F2 — dobradinhas de liderança:** rota `POST /campanha/liderancas/state-deputies` (delta - `{ leadershipId, stateDeputyId, assigned }`, allowlist do cap `LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`). + `{ leadershipId, stateDeputyId, assigned }`, allowlist do cap `LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`), + servindo os chips de `/campanha/liderancas` (B31) e `/campanha/dobradinhas` (B36). - **F3 — municípios de liderança:** rota `POST /campanha/liderancas/municipalities` (delta `{ leadershipId, municipalityIds[], assigned }` — inclui o batch território/ZE; allowlist de floor/cap/scope). Cada fase reusa as **actions record já existentes** (sem mudança de domínio): o trabalho é -transport (rota + allowlist) + o controle trocando `useActionState`/form action por -`postCampaignJson` com o mesmo estado otimista/debounce que já tem. +transport (rota + allowlist) + o call site trocando a form action por um shim cliente +`postCampaignJson` com o mesmo estado otimista que as shared cells já têm. ## Já resolvido no simplify (não reabrir) +- **F1 (sinal na lista de municípios)** — resolvido por C87 (2026-08-07): o quick-edit virou form + multi-campo `MunicipalityListUpdateControl`; per a regra B155 essa superfície permanece em + server action (`createMunicipalityListUpdateFormAction`). Nada a migrar, não reabrir. - Reconcile scoped das wrappers B155 (sem bypass novo), toggle revalidando `/campanha/liderancas`, nome real do contato no chip otimista, validação cliente espelhando o schema, ids `useId`, refoco pós-form — todos aplicados na sessão B155. @@ -65,23 +74,24 @@ transport (rota + allowlist) + o controle trocando `useActionState`/form action ## Fases verificáveis -1. **F1** — sinal de municípios (mesma superfície já no padrão; ROI mais alto). -2. **F2 + F3** — colunas de `/campanha/liderancas`. -3. **Gates** — `pnpm gate:fast`; e2e afetado (campaignMunicipalities, campaignLeaderships); +1. **F2 + F3** — colunas de `/campanha/liderancas` (+ B36 em `/campanha/dobradinhas`, mesma rota). +2. **Gates** — `pnpm gate:fast`; e2e afetado (campaignLeaderships, campaignMunicipalities); `pnpm push`. ## Rabbit holes - **Generalizar** os controles/providers de chips durante a migração — refactor de transporte - apenas; os 2+2 controles seguem espelhos. -- **Mudar o contrato do sinal** (hoje debounce com flush no close) — preservar - `useCampaignCellAutosave` behavior no novo transporte. + apenas; os controles espelho (dobradinhas B37/B156, inline-edits B153/B163/C129) seguem + em form action até o lote próprio. +- **Mudar o contrato do sinal** — não se aplica mais (F1 morta por C87); sem debounce/autosave + novo no lote. - **Fundir com UX** — nenhum achado de critique nesta superfície; manter o lote só-engenharia. ## Riscos e mitigação -- Controles de lideranças hoje acionam form actions com revalidate via server action — a rota - precisa replicar o `revalidatePath` equivalente (as actions record reusadas já revalidam - paths de detalhe; a lista onde o chip está NÃO é revalidada — contrato B34/B31). +- A rota reusa as actions record (que já revalidam paths de detalhe; a lista onde o chip está + NÃO é revalidada — contrato B34/B31) — o revalidate vem das actions, zero linhas na rota; + adicionar a lista seria mudança de comportamento, não de transporte. - `codebaseConventions` (rota obrigatória via `campaignJsonMutationRoute`) e o prewarm do e2e - (`setup.e2e.spec.ts`) ganham as rotas novas no mesmo PR. + (`setup.e2e.spec.ts`) ganham as rotas novas no mesmo PR; o e2e B34 troca o filtro de POST da + URL da página para a rota nova. diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts index aeafd5ac..b61b1129 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts @@ -1,6 +1,5 @@ 'use server' -import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership' import { setStateDeputyAdvisorMembership, setStateDeputyMunicipalitiesBatch, @@ -16,10 +15,6 @@ import { requiredFormText, requiredRelationshipFormValue, } from '@/lib/formData' -import { - LEADERSHIP_STAFF_MESSAGE, - LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, -} from '@/lib/schemas/leadership' import { STATE_DEPUTY_ADVISOR_SAFE_MESSAGES, STATE_DEPUTY_CONFLICT_MESSAGE, @@ -33,8 +28,6 @@ import { type CampaignFormActionState, } from '@/utilities/campaignFormActionError' -const safeMessages = [LEADERSHIP_STAFF_MESSAGE, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE] as const - const stateDeputyContactSafeMessages = [ STATE_DEPUTY_STAFF_MESSAGE, STATE_DEPUTY_CONFLICT_MESSAGE, @@ -125,24 +118,6 @@ export const updateStateDeputyBallotNameFormAction = async ( 'Não foi possível salvar o nome de legenda. Verifique os dados e tente novamente.', }) -/** One chip toggle in the "Lideranças" column of `/campanha/dobradinhas` (B36). */ -export const setLeadershipStateDeputyMembershipFormAction = async ( - _state: CampaignFormActionState, - formData: FormData, -): Promise => - runCampaignFormAction({ - execute: async () => { - await setLeadershipStateDeputyMembership({ - leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'), - stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'), - assigned: requiredFormBoolean(formData, 'assigned'), - }) - return { message: 'Lideranças atualizadas.' } - }, - safeMessages, - genericMessage: 'Não foi possível atualizar as lideranças. Tente novamente.', - }) - /** One chip toggle in the "Assessores" column/section of a dobradinha (B156). */ export const setStateDeputyAdvisorMembershipFormAction = async ( _state: CampaignFormActionState, diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx index 609d10ea..44210b2c 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { redirect } from 'next/navigation' import { getPayload } from 'payload' +import { LeadershipStateDeputiesColumnCell } from '@/components/campaign/leadership/LeadershipStateDeputiesColumnCell' import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing' import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' @@ -19,10 +20,7 @@ import { CampaignTableHead, type CampaignTableColumn, } from '@/components/campaign/shared/CampaignTable' -import { - LeadershipStateDeputyRelationCell, - type RelationCellOption, -} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell' +import type { RelationCellOption } from '@/components/campaign/shared/LeadershipStateDeputyRelationCell' import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { StateDeputyAdvisorRelationCell } from '@/components/campaign/stateDeputy/StateDeputyAdvisorRelationCell' @@ -69,7 +67,6 @@ import { } from '@/utilities/stateDeputyListUrl' import { - setLeadershipStateDeputyMembershipFormAction, setStateDeputyAdvisorMembershipFormAction, setStateDeputyMunicipalitiesFormAction, updateStateDeputyBallotNameFormAction, @@ -253,7 +250,7 @@ const stateDeputyColumns = ( label: 'Lideranças', cellClassName: 'max-w-72 whitespace-normal', cell: (row) => ( - ), }, diff --git a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts index 8d9d409b..77877ae7 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts @@ -1,26 +1,14 @@ 'use server' import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages' -import { - setLeadershipMunicipalitiesMembership, - setLeadershipStateDeputyMembership, - updateLeadershipContact, -} from '@/app/(campaign)/campanha/actions/leadership' +import { updateLeadershipContact } from '@/app/(campaign)/campanha/actions/leadership' import { nullableFormText, repeatedPhoneFormValues, - repeatedRelationshipFormValues, - requiredFormBoolean, requiredFormText, requiredRelationshipFormValue, } from '@/lib/formData' -import { - LEADERSHIP_INVALID_CONTACT_MESSAGE, - LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, - LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, - LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, - LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, -} from '@/lib/schemas/leadership' +import { LEADERSHIP_INVALID_CONTACT_MESSAGE } from '@/lib/schemas/leadership' import { runCampaignFormAction, type CampaignFormActionState, @@ -31,10 +19,6 @@ const INVALID_FIELD_MESSAGE = 'Campo inválido.' const safeMessages = [ ...leadershipStaffEditSafeMessages, LEADERSHIP_INVALID_CONTACT_MESSAGE, - LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, - LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, - LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, - LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, INVALID_FIELD_MESSAGE, ] as const @@ -81,44 +65,3 @@ export const updateLeadershipContactFormAction = async ( safeMessages, genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.', }) - -/** One chip toggle in the "Dobradinhas" column of `/campanha/liderancas` (B31). */ -export const setLeadershipStateDeputyMembershipFormAction = async ( - _state: CampaignFormActionState, - formData: FormData, -): Promise => - runCampaignFormAction({ - execute: async () => { - await setLeadershipStateDeputyMembership({ - leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'), - stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'), - assigned: requiredFormBoolean(formData, 'assigned'), - }) - return { message: 'Dobradinhas atualizadas.' } - }, - safeMessages, - genericMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.', - }) - -/** - * Add or remove municipalities in the "Municípios" column of - * `/campanha/liderancas` (B34) — one chip or a whole território/ZE. `ownerId` is - * the field name `MunicipalityPortfolioCell` sends, so the same cell serves - * lideranças and assessores. - */ -export const setLeadershipMunicipalitiesFormAction = async ( - _state: CampaignFormActionState, - formData: FormData, -): Promise => - runCampaignFormAction({ - execute: async () => { - await setLeadershipMunicipalitiesMembership({ - leadershipId: requiredRelationshipFormValue(formData, 'ownerId'), - municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'), - assigned: requiredFormBoolean(formData, 'assigned'), - }) - return { message: 'Municípios atualizados.' } - }, - safeMessages, - genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.', - }) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/municipalities/route.ts b/src/app/(campaign)/campanha/(app)/liderancas/municipalities/route.ts new file mode 100644 index 00000000..619c72bb --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/liderancas/municipalities/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server' + +import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages' +import { setLeadershipMunicipalitiesMembership } from '@/app/(campaign)/campanha/actions/leadership' +import { + LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, + LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, + leadershipMunicipalitiesMembershipSchema, +} from '@/lib/schemas/leadership' +import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' + +import type { LeadershipListMunicipalitiesResponse } from './types' + +export type { LeadershipListMunicipalitiesResponse } from './types' + +export const dynamic = 'force-dynamic' + +export const POST = campaignJsonMutationRoute( + { + bodySchema: leadershipMunicipalitiesMembershipSchema, + safeMessages: [ + ...leadershipStaffEditSafeMessages, + LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, + LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, + ], + genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.', + }, + async (body) => { + await setLeadershipMunicipalitiesMembership(body) + + return NextResponse.json({ + status: 'success', + message: 'Municípios atualizados.', + }) + }, +) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/municipalities/types.ts b/src/app/(campaign)/campanha/(app)/liderancas/municipalities/types.ts new file mode 100644 index 00000000..5481b90d --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/liderancas/municipalities/types.ts @@ -0,0 +1,9 @@ +export type LeadershipListMunicipalitiesResponse = + | { + status: 'success' + message: string + } + | { + status: 'error' + message: string + } diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index 7fec4f86..c8e02d69 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -7,10 +7,12 @@ import { getPayload } from 'payload' import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction' import { LeadershipFilters } from '@/components/campaign/leadership/LeadershipFilters' import { LeadershipListSupportStatusControl } from '@/components/campaign/leadership/LeadershipListSupportStatusControl' +import { LeadershipMunicipalitiesColumnCell } from '@/components/campaign/leadership/LeadershipMunicipalitiesColumnCell' import { LeadershipFilterHead, LeadershipSortableHead, } from '@/components/campaign/leadership/LeadershipSortableHead' +import { LeadershipStateDeputiesColumnCell } from '@/components/campaign/leadership/LeadershipStateDeputiesColumnCell' import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing' import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' @@ -25,11 +27,7 @@ import { CampaignTableHead, type CampaignTableColumn, } from '@/components/campaign/shared/CampaignTable' -import { - LeadershipStateDeputyRelationCell, - type RelationCellOption, -} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell' -import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell' +import type { RelationCellOption } from '@/components/campaign/shared/LeadershipStateDeputyRelationCell' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Badge } from '@/components/ui/Badge' import { Button, buttonVariants } from '@/components/ui/button' @@ -79,11 +77,7 @@ import { loadOrganizationNamesByIds } from '@/utilities/loadNamesByIds' import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' import { loadStateDeputySummaries } from '@/utilities/stateDeputyData' -import { - setLeadershipMunicipalitiesFormAction, - setLeadershipStateDeputyMembershipFormAction, - updateLeadershipContactFormAction, -} from './formActions' +import { updateLeadershipContactFormAction } from './formActions' export const metadata = campaignPageMetadataFromCatalog('liderancas') @@ -229,7 +223,7 @@ const leadershipColumns = ({ ), cellClassName: 'max-w-64 whitespace-normal', cell: (row) => ( - @@ -254,7 +247,7 @@ const leadershipColumns = ({ label: 'Dobradinhas', cellClassName: 'max-w-56 whitespace-normal', cell: (row) => ( - ), diff --git a/src/app/(campaign)/campanha/(app)/liderancas/state-deputies/route.ts b/src/app/(campaign)/campanha/(app)/liderancas/state-deputies/route.ts new file mode 100644 index 00000000..7d96668a --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/liderancas/state-deputies/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' + +import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages' +import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership' +import { + LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, + leadershipStateDeputyMembershipSchema, +} from '@/lib/schemas/leadership' +import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' + +import type { LeadershipListStateDeputiesResponse } from './types' + +export type { LeadershipListStateDeputiesResponse } from './types' + +export const dynamic = 'force-dynamic' + +export const POST = campaignJsonMutationRoute( + { + bodySchema: leadershipStateDeputyMembershipSchema, + safeMessages: [...leadershipStaffEditSafeMessages, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE], + genericMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.', + }, + async (body) => { + await setLeadershipStateDeputyMembership(body) + + return NextResponse.json({ + status: 'success', + message: 'Dobradinhas atualizadas.', + }) + }, +) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/state-deputies/types.ts b/src/app/(campaign)/campanha/(app)/liderancas/state-deputies/types.ts new file mode 100644 index 00000000..4521146e --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/liderancas/state-deputies/types.ts @@ -0,0 +1,9 @@ +export type LeadershipListStateDeputiesResponse = + | { + status: 'success' + message: string + } + | { + status: 'error' + message: string + } diff --git a/src/components/campaign/leadership/LeadershipMunicipalitiesColumnCell.tsx b/src/components/campaign/leadership/LeadershipMunicipalitiesColumnCell.tsx new file mode 100644 index 00000000..118c5acf --- /dev/null +++ b/src/components/campaign/leadership/LeadershipMunicipalitiesColumnCell.tsx @@ -0,0 +1,51 @@ +'use client' + +import { useMemo, type ComponentProps } from 'react' + +import type { LeadershipListMunicipalitiesResponse } from '@/app/(campaign)/campanha/(app)/liderancas/municipalities/types' +import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell' +import { postCampaignJson } from '@/lib/campaignJsonRequest' +import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' + +const ENDPOINT = '/campanha/liderancas/municipalities' +const GENERIC_ERROR = 'Não foi possível atualizar os municípios. Tente novamente.' + +type Props = Omit, 'commitAction'> + +/** + * Thin adapter (B155+): wraps `MunicipalityPortfolioCell` with a route-based + * `commitAction` that posts JSON to `POST /campanha/liderancas/municipalities` + * — the cell sends `ownerId`, which is the leadership id on this call site. + */ +export const LeadershipMunicipalitiesColumnCell = (props: Props) => { + const commitAction = useMemo( + () => + async ( + _state: CampaignFormActionState, + formData: FormData, + ): Promise => { + const leadershipId = Number(formData.get('ownerId')) + const assigned = formData.get('assigned') === 'true' + const municipalityIds = formData.getAll('municipalityIds').map(Number) + + try { + const { ok, payload } = await postCampaignJson( + ENDPOINT, + { leadershipId, municipalityIds, assigned }, + ) + + if (!ok || payload.status !== 'success') { + return { + message: payload.status === 'error' ? payload.message : GENERIC_ERROR, + } + } + return { status: 'success' as const, message: payload.message } + } catch { + return { message: GENERIC_ERROR } + } + }, + [], + ) + + return +} diff --git a/src/components/campaign/leadership/LeadershipStateDeputiesColumnCell.tsx b/src/components/campaign/leadership/LeadershipStateDeputiesColumnCell.tsx new file mode 100644 index 00000000..7efce9da --- /dev/null +++ b/src/components/campaign/leadership/LeadershipStateDeputiesColumnCell.tsx @@ -0,0 +1,52 @@ +'use client' + +import { useMemo, type ComponentProps } from 'react' + +import type { LeadershipListStateDeputiesResponse } from '@/app/(campaign)/campanha/(app)/liderancas/state-deputies/types' +import { LeadershipStateDeputyRelationCell } from '@/components/campaign/shared/LeadershipStateDeputyRelationCell' +import { postCampaignJson } from '@/lib/campaignJsonRequest' +import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' + +const ENDPOINT = '/campanha/liderancas/state-deputies' +const GENERIC_ERROR = 'Não foi possível atualizar as dobradinhas. Tente novamente.' + +type Props = Omit, 'membershipAction'> + +/** + * Thin adapter (B155+): wraps `LeadershipStateDeputyRelationCell` with a + * route-based `membershipAction` that posts JSON to + * `POST /campanha/liderancas/state-deputies` — same `(state, formData)` + * contract the cell already calls, transport swapped from server action. + */ +export const LeadershipStateDeputiesColumnCell = (props: Props) => { + const membershipAction = useMemo( + () => + async ( + _state: CampaignFormActionState, + formData: FormData, + ): Promise => { + const leadershipId = Number(formData.get('leadershipId')) + const stateDeputyId = Number(formData.get('stateDeputyId')) + const assigned = formData.get('assigned') === 'true' + + try { + const { ok, payload } = await postCampaignJson( + ENDPOINT, + { leadershipId, stateDeputyId, assigned }, + ) + + if (!ok || payload.status !== 'success') { + return { + message: payload.status === 'error' ? payload.message : GENERIC_ERROR, + } + } + return { status: 'success' as const, message: payload.message } + } catch { + return { message: GENERIC_ERROR } + } + }, + [], + ) + + return +} diff --git a/tests/e2e/campaignLeaderships.e2e.spec.ts b/tests/e2e/campaignLeaderships.e2e.spec.ts index d52b5d1c..060ac838 100644 --- a/tests/e2e/campaignLeaderships.e2e.spec.ts +++ b/tests/e2e/campaignLeaderships.e2e.spec.ts @@ -97,9 +97,9 @@ test.describe('campaign leaderships list', () => { page.getByRole('button', { name: new RegExp(`não é possível remover ${linked.name}$`) }), ).toBeDisabled() - // Server actions POST to the page URL, so the response filter is the route - // itself — the optimistic chip renders before the write lands. - const persisted = () => expectPostResponse(page, '/campanha/liderancas') + // The chip cell posts JSON to its mutation route — the optimistic chip + // renders before the write lands. + const persisted = () => expectPostResponse(page, '/campanha/liderancas/municipalities') const search = page.getByRole('combobox', { name: 'Buscar município, território de identidade ou zona eleitoral', diff --git a/tests/e2e/setup.e2e.spec.ts b/tests/e2e/setup.e2e.spec.ts index 94991fef..bb50980c 100644 --- a/tests/e2e/setup.e2e.spec.ts +++ b/tests/e2e/setup.e2e.spec.ts @@ -80,6 +80,8 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => { '/campanha/municipios/political-trend', '/campanha/municipios/next-steps', '/campanha/liderancas/support-status', + '/campanha/liderancas/state-deputies', + '/campanha/liderancas/municipalities', '/campanha/home-search', // The Sollinha chat mounts on every authenticated page: its transport and // the PWA registration hit these on first load/session-restore — cold diff --git a/tests/unit/sharedSheetHostConventions.unit.spec.ts b/tests/unit/sharedSheetHostConventions.unit.spec.ts index 101cf27e..5fd29656 100644 --- a/tests/unit/sharedSheetHostConventions.unit.spec.ts +++ b/tests/unit/sharedSheetHostConventions.unit.spec.ts @@ -20,7 +20,14 @@ describe('shared sheet host for chip-relation cells (miss #52)', () => { * table (their owning page holds the provider — checked for the C116 people * list like the dobradinhas one). */ - const cellWrapperFiles = new Set(['src/components/campaign/people/PeopleMunicipalityCell.tsx']) + const cellWrapperFiles = new Set([ + // C128 people list; C116 batch-chip expansion state — the owning page + // holds the provider. + 'src/components/campaign/people/PeopleMunicipalityCell.tsx', + // B155+ route-backed column adapters — the owning pages hold the provider. + 'src/components/campaign/leadership/LeadershipMunicipalitiesColumnCell.tsx', + 'src/components/campaign/leadership/LeadershipStateDeputiesColumnCell.tsx', + ]) it('wraps every chip-relation table in CampaignListSheetProvider', () => { const offenders: string[] = []