` (com o tipo morreram o helper, o `useMemo` que só existia porque ele alocava por chamada, o `| null` do `MapFeatureReadout` e **435 strings no payload RSC de toda visita ao Início**); `polygonRingsOf` exportado; helpers de geometria retipados em `PolygonalFeature` (o requisito real, em vez de `BahiaGeometryFeature` que arrasta `GeoJsonProperties`, ou seja `any`), mais `BahiaFeature` e `BahiaMeshFeature`. **Um item do plano foi rejeitado por medição:** `city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` **ficam** — nenhum runtime os lê, mas os dois entram nas linhas hasheadas por `canonicalEvidenceRows` e são colunas do fixture de proveniência da RA 02/2017 (transcrição **independente** da resolução), então deletar enfraqueceria a verificação por ganho zero; só o `zoneNumber` do **artefato** saiu. Sem migration, collection, action ou `Consent`; e2e segue 436 paths / 435 interativos. Gate completo rodado bare contra o banco local (as falhas de e2e encontradas foram reproduzidas em árvore limpa — flakiness pré-existente já no ledger); Aikido **0 findings** nos arquivos editados (desta vez o scanner rodou; o exit code 2 do B8 F2 não se repetiu para o SAST). Plano as-built: [`escala-dry-pos-b8f2.md`](../../../docs/plans/escala-dry-pos-b8f2.md).
- **`users.roles` entregue em código (2026-07-27):** campo `roles` (`admin` | `editor`) em `users` — migration `20260727_032523_add_users_roles` (enum + tabela `users_roles` + backfill `admin` nas rows existentes). `isPayloadAdmin` exige papel `admin` (falha fechado se ausente/vazio); `hasPayloadPanelAccess` / `canManagePublishedContent` liberam editor em `/admin` + `post`/`tag`/`media`; `petition`/PII/campanha seguem admin-only; `canReadMunicipality` nega ator `users` sem admin (antes devolvia `Where` vazio e o editor via Municípios no sidebar). Unificação `users`+`campaignUser` **rejeitada** nesta sessão (25 FKs, isolamento estrutural→dado, janela de congelamento) — gatilho: Fase 2 white-label ou dor real de duas contas. Plano: [`roles-admin-payload.md`](../../../docs/plans/roles-admin-payload.md).
- **Build resiliente ao global `metadata` ausente entregue em código (2026-07-27):** site público. `resolveSiteMetadata` + `SITE_METADATA_DEFAULTS` em `src/utilities/seo.ts` — cadeia `global.URL` → `NEXT_PUBLIC_SITE_URL` → `siteUrl: null` (chamador omite canonical / OG url / JSON-LD url); defaults textuais (`siteName`/`title`/`description`/`twitterCreator`) reusam o que o layout já hardcodava. Consumidores: `layout.tsx`, `/abaixo-assinado/[id]`, `/[type]`, `/[type]/[category]`, `/[type]/[category]/[slug]`. Cópias locais de `stripTrailingSlash`/`truncate`/`toAbsoluteUrl` na página de abaixo-assinado removidas. Pins: unit `siteMetadata`, int `siteMetadataFallback` (mock `getCachedGlobal` → `{}`), guard em `codebaseConventions` contra `globalMetadata.openGraph.` / `.twitter.` / `.keywords.` / `stripTrailingSlash(globalMetadata.URL)` fora de `seo.ts`. Escopo ampliado na auditoria (roadmap citava só a rota de petição e só `URL`). Sem migration/collection/action. Gate: tsc/lint/format/cycles/unit 548/build verde; knip P3 pré-existente; Aikido sem achados novos (`dangerouslySetInnerHTML` Lexical/JSON-LD pré-existente). Plano: [`build-resiliente-metadata-ausente.md`](../../../docs/plans/build-resiliente-metadata-ausente.md).
diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md
index 4ca091cf..dfb85de0 100644
--- a/docs/TECH-DEBT.md
+++ b/docs/TECH-DEBT.md
@@ -28,19 +28,19 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
## Smell Inventory
-| Smell | Location | Refactoring | Status |
-| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| God module: RBAC for every collection in one file (964 lines, ~80 exports) | `src/utilities/campaignAccess.ts` | split per domain under `src/utilities/access/` | done 2026-07-23 (14 domain modules; `campaignAccess.ts` kept as re-export surface) |
-| God module: ~71 exports of electoral math | `src/lib/electionInsights.ts` | split by concern when next touched | done 2026-07-25 (Pass 2 W4a): module DELETED; survivors in `electionFormat.ts` + `voteTrend.ts` |
-| Multi-feature action bag: CRUD + CSV import pipeline + LGPD in one file (722 lines) | `src/app/(campaign)/campanha/actions/supporter.ts` | split CRUD vs import | done 2026-07-23 (`supporterImport.ts`) |
-| Clone twins: create/update/staff-assert/unique-conflict skeleton | `actions/organization.ts` ↔ `actions/stateDeputy.ts` | extract shared factory | done 2026-07-23 (`runStaffEntityMutation` in `campaignEntityActions.ts`) |
-| 14 near-identical `formActions.ts` try/catch→mapError→redirect/return ladders | `src/app/(campaign)/campanha/**/formActions.ts` | shared form-action wrapper | partially done — C8 F4 (2026-07-21/23) unified the municipality staff wrappers + 4 `*/nova` ladders on `runCampaignRedirectFormAction`; **10 ladders written since remain hand-rolled** (844 lines: assessores, planos, apoiadores ×2, demandas/[slug], liderancas/[id], organizacoes/[slug], dobradinhas/[slug], municipios editar, convite) — done 2026-07-25 (Pass 2 W4d): `runCampaignFormAction` + 13 ações migradas; exceções documentadas em comentário (planos, apoiadores/[id], convite login) |
-| Flat 80+-file `src/utilities/` junk drawer; `lib/` vs `utilities/` boundary undocumented | `src/utilities/`, `src/lib/` | document convention + move misfiled modules | done 2026-07-23 (convention in `.cursor/rules/engineering-standards.mdc`; `RefreshRouteOnSave` moved to `components/`) |
-| Praça→Município rename leftovers in identifiers (`PLAZA_MAP_YEARS`, `ACCESSIBLE_PLAZA_IDS_CONTEXT_KEY`, …) | map/list utilities | finish rename | done 2026-07-23 |
-| Props→state `useEffect` mirrors | `MunicipalityListExpectedVotesControl`, comboboxes | key-remount or fully controlled | closed 2026-07-23 (accepted as correct — see Debt Ledger row) |
-| Detail-page RSCs mixing loading + tab UI + forms (300+ lines) | `municipios/[slug]/page.tsx`, `planos/[slug]/page.tsx` | extract tab loaders/sections | done 2026-07-25 (Pass 2 W4c): tabs em `MunicipalityDetailTabs.tsx` / `ActionPlanOverviewTab.tsx`; gêmeos de tab dedupados em `detailTabUi.ts` |
-| God module: list URL + filters + sort + labels + freshness in one file (765 lines, 59 exports) | `src/utilities/municipalityUi.ts` | split along url/filters/sort/labels/freshness seams | done 2026-07-25 (Pass 2 W1-D1): `municipalityListUrl` / `municipalityListFilters` / `municipalityLabels` / `municipalitySignal` |
-| `components/campaign/` flat with 106 files; `utilities/` flat with 92 | `src/components/campaign/`, `src/utilities/` | within-layer domain subfolders | components done 2026-07-25 (Pass 2 W2, 15 domínios); `utilities/` deferred with trigger: a domain gaining its 3rd new module in a month gets its subfolder |
+| Smell | Location | Refactoring | Status |
+| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| God module: RBAC for every collection in one file (964 lines, ~80 exports) | `src/utilities/campaignAccess.ts` | split per domain under `src/utilities/access/` | done 2026-07-23 (14 domain modules; `campaignAccess.ts` kept as re-export surface) |
+| God module: ~71 exports of electoral math | `src/lib/electionInsights.ts` | split by concern when next touched | done 2026-07-25 (Pass 2 W4a): module DELETED; survivors in `electionFormat.ts` + `voteTrend.ts` |
+| Multi-feature action bag: CRUD + CSV import pipeline + LGPD in one file (722 lines) | `src/app/(campaign)/campanha/actions/supporter.ts` | split CRUD vs import | done 2026-07-23 (`supporterImport.ts`) |
+| Clone twins: create/update/staff-assert/unique-conflict skeleton | `actions/organization.ts` ↔ `actions/stateDeputy.ts` | extract shared factory | done 2026-07-23 (`runStaffEntityMutation` in `campaignEntityActions.ts`) |
+| 14 near-identical `formActions.ts` try/catch→mapError→redirect/return ladders | `src/app/(campaign)/campanha/**/formActions.ts` | shared form-action wrapper | done 2026-07-25 (Pass 2 W4d): `runCampaignFormAction` + 13 ações migradas; exceções documentadas (atividades/formActions, apoiadores/[id], convite login). **C8 F4b ✓ 2026-07-27:** guard ampliado para `*FormActions.ts` + `taskActions.ts`; 6 irmãos migrados; `taskActions.ts` allowlisted — futuros siblings não driftam silenciosamente |
+| Flat 80+-file `src/utilities/` junk drawer; `lib/` vs `utilities/` boundary undocumented | `src/utilities/`, `src/lib/` | document convention + move misfiled modules | done 2026-07-23 (convention in `.cursor/rules/engineering-standards.mdc`; `RefreshRouteOnSave` moved to `components/`) |
+| Praça→Município rename leftovers in identifiers (`PLAZA_MAP_YEARS`, `ACCESSIBLE_PLAZA_IDS_CONTEXT_KEY`, …) | map/list utilities | finish rename | done 2026-07-23 |
+| Props→state `useEffect` mirrors | `MunicipalityListExpectedVotesControl`, comboboxes | key-remount or fully controlled | closed 2026-07-23 (accepted as correct — see Debt Ledger row) |
+| Detail-page RSCs mixing loading + tab UI + forms (300+ lines) | `municipios/[slug]/page.tsx`, `planos/[slug]/page.tsx` | extract tab loaders/sections | done 2026-07-25 (Pass 2 W4c): tabs em `MunicipalityDetailTabs.tsx` / `ActionPlanOverviewTab.tsx`; gêmeos de tab dedupados em `detailTabUi.ts` |
+| God module: list URL + filters + sort + labels + freshness in one file (765 lines, 59 exports) | `src/utilities/municipalityUi.ts` | split along url/filters/sort/labels/freshness seams | done 2026-07-25 (Pass 2 W1-D1): `municipalityListUrl` / `municipalityListFilters` / `municipalityLabels` / `municipalitySignal` |
+| `components/campaign/` flat with 106 files; `utilities/` flat with 92 | `src/components/campaign/`, `src/utilities/` | within-layer domain subfolders | components done 2026-07-25 (Pass 2 W2, 15 domínios); `utilities/` deferred with trigger: a domain gaining its 3rd new module in a month gets its subfolder |
## Sprout / Wrap Register
diff --git a/docs/plans/escala-dry-pos-c6.md b/docs/plans/escala-dry-pos-c6.md
index b3a58ebd..6ce6430e 100644
--- a/docs/plans/escala-dry-pos-c6.md
+++ b/docs/plans/escala-dry-pos-c6.md
@@ -1,7 +1,7 @@
# Escala e DRY pós-C6 (apoiadores / import / listas)
-Status: F4 (DRY de forms) **entregue** — twin praça 2026-07-21 + hardening 2026-07-23; **o resto do F4 (10 escadas hand-rolled escritas depois) fechou em 2026-07-25 no Pass 2 W4d** (`runCampaignFormAction`, exceções documentadas em comentário); **F3 (parsers das listas de entidade) fechou no Pass 2 W1-D2**; **F4b aberta** (o guard do F4 só inspeciona `formActions.ts`, deixando os arquivos de action irmãos sem verificação — achado do `/simplify` do C13); F1–F2 abertos com gatilho (volume real de import/base nominal pós-Onda 0)
-Atualizado em: 2026-07-25 (F4b registrada pelo `capture-review-debts` pós-C13)
+Status: F4 (DRY de forms) **entregue** — twin praça 2026-07-21 + hardening 2026-07-23; **o resto do F4 (10 escadas hand-rolled escritas depois) fechou em 2026-07-25 no Pass 2 W4d** (`runCampaignFormAction`, exceções documentadas em comentário); **F3 (parsers das listas de entidade) fechou no Pass 2 W1-D2**; **F4b entregue 2026-07-27** (guard ampliado para `*FormActions.ts` + `taskActions.ts`; 6 irmãos migrados; `taskActions.ts` allowlisted); F1–F2 abertos com gatilho (volume real de import/base nominal pós-Onda 0)
+Atualizado em: 2026-07-27 (F4b entregue — guard + migração dos irmãos)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, item C8)
Responsável: —
@@ -88,13 +88,27 @@ flowchart TD
- ~~Migrar `fieldError`/`firstError` local em forms legados~~ — `CampaignInviteForm.tsx` já importa de `campaignFormFields.ts`.
- **Nota pós-remodel:** paths `nucleos/*`/`pracas/*` foram superseded; não reabrir.
-### Fase 4b — o guard do F4 só vê `formActions.ts` — **ABERTA** (registrada 2026-07-25 pós-C13)
+### Fase 4b — o guard do F4 só vê `formActions.ts` — **ENTREGUE** (2026-07-27)
-Appetite: **~2h eng, fill-in.** O `/simplify` do C13 mostrou que a invariante do F4 é menor do que o texto acima sugere: o guard `campaign formActions convention` (`tests/unit/codebaseConventions.unit.spec.ts`) filtra `file.endsWith('/formActions.ts')`, então os arquivos de action irmãos que cada rota escreveu depois — `lifecycleFormActions.ts`, `resultFormActions.ts`, `updateFormActions.ts`, `taskActions.ts` — **não são verificados em nenhuma rota**. A exceção documentada na Revisão 2026-07-24 ("`lifecycleFormActions.ts` só retorna safe-messages fixas — sem ladder para migrar") foi escrita quando o alvo era `mapCampaignFormActionError`; com `runCampaignRedirectFormAction` (Pass 2 W4d) a conta mudou, porque esse arquivo faz exatamente a forma que o wrapper padroniza: dois `catch` nus que descartam o erro + `redirect` no sucesso.
+Appetite: **~2h eng, fill-in.** O `/simplify` do C13 mostrou que a invariante do F4 é menor do que o texto acima sugere: o guard `campaign formActions convention` (`tests/unit/codebaseConventions.unit.spec.ts`) filtrava `file.endsWith('/formActions.ts')`, então os arquivos de action irmãos que cada rota escreveu depois — `lifecycleFormActions.ts`, `resultFormActions.ts`, `updateFormActions.ts`, `taskActions.ts` — **não eram verificados em nenhuma rota**. A exceção documentada na Revisão 2026-07-24 ("`lifecycleFormActions.ts` só retorna safe-messages fixas — sem ladder para migrar") foi escrita quando o alvo era `mapCampaignFormActionError`; com `runCampaignRedirectFormAction` (Pass 2 W4d) a conta mudou, porque esse arquivo faz exatamente a forma que o wrapper padroniza: dois `catch` nus que descartam o erro + `redirect` no sucesso.
-- Ampliar o filtro do guard para os irmãos (`/^\w*[Ff]ormActions\.ts$/` + `taskActions.ts`, ou um sufixo declarado) e allowlistar com racional escrito o que realmente não couber.
-- Migrar o que o wrapper cobre; onde o `catch` nu ficar, decidir se o erro deve ser observável (hoje é descartado sem log em ambos os caminhos, então não é um débito diferencial — verificar no PR antes de inflar o escopo).
-- Não escopo: mudar a semântica das safe-messages (copy revisada) e tocar `actions/*.ts` (as server actions em si já usam transação/lock).
+**As-built:**
+
+- Guard ampliado para basename `/^\w*[Ff]ormActions\.ts$/` + `taskActions.ts`.
+- Migrados para `runCampaignRedirectFormAction` / `runCampaignFormAction`: `atividades/[slug]/lifecycleFormActions.ts`, `resultFormActions.ts`, `updateFormActions.ts`; `municipios/[slug]/updateFormActions.ts`, `pledgeFormActions.ts`; `municipios/municipalityStaffFormActions.ts` (4 actions).
+- Allowlist com racional: `taskActions.ts` (contrato posicional); `atividades/formActions.ts` (unique-violation + duplicate-title); `apoiadores/[id]/formActions.ts` (message-only flatten).
+- Guard exige call site `runCampaign…FormAction(` — comentário nomeando o wrapper não passa.
+- Docstring de `runCampaignFormAction` precisada: as exceções hand-rolled são só os dois allowlisted acima.
+- `/simplify` pós-F4b: pre-parse de `id` em `lifecycleFormActions` só engole `FormDataBoundaryError`.
+
+**Já resolvido no simplify (não reabrir):** loophole de comment-token no guard; `catch` nu do lifecycle id.
+
+**Explicitamente fora / Adiado com gatilho (triage 2026-07-27):**
+
+- `actions/password.ts` / `profile.ts` / `leaderSupporter.ts` exportam `*FormAction` com ladder hand-rolled fora do filtro `*FormActions.ts` — gatilho: próximo toque nesses arquivos **ou** decisão de expandir o guard além de route-level FormActions.
+- `convite/[token]/formActions.ts` login half ainda hand-rolled (password-confirm + redirect) — gatilho: próximo toque no convite; autofill já usa o wrapper.
+
+**Revisão 2026-07-27:** F4b fechada; F1–F2 seguem com gatilho de volume.
**Migration:** Fases 1, 3, 4, 4b sem schema. Fase 2: `pnpm migrate:create add_contact_trgm_index` (extensão + 2 GIN indexes). Sem Consent novo.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index bffd2e2d..0885f65a 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-27 (**Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
+Atualizado em: 2026-07-27 (**C8 F4b ✓** guard de `formActions` ampliado para irmãos `*FormActions.ts` + `taskActions.ts`; 6 ladders migradas; `taskActions.ts` allowlisted · [plano](plans/escala-dry-pos-c6.md); **Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); ~~**C8 F4b**~~ ✓ (entregue 2026-07-27); **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
@@ -143,7 +143,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **RS+** auth read leve + shells de senha · [plano](plans/escala-dry-pos-reset-senha-perfil.md)
- **C10 / C11** escala apoiadores/planos · C11 absorveu a extensão pós-C12 para picker de planos e paginação das demandas vinculadas (Fase 6 condicional; ≥100 planos ou ≥50 demandas/plano) · gatilhos: base nominal crescendo ([plano](plans/escala-dry-pos-c9.md)) / volume de planos medido ([plano](plans/escala-dry-pos-c7.md))
- **C8 F1–F2 restantes** perf de import/lista (F3 parsers fechou no Pass 2 W1-D2) · gatilho: import em volume real pós-Onda 0 · [plano](plans/escala-dry-pos-c6.md)
-- **C8 F4b** o guard de `formActions` só inspeciona arquivos chamados exatamente `formActions.ts`, então os irmãos que cada rota escreveu depois (`lifecycleFormActions.ts`, `resultFormActions.ts`, `updateFormActions.ts`, `taskActions.ts`) ficaram sem verificação — ampliar o filtro e migrar o que `runCampaignRedirectFormAction` cobrir · achado do `/simplify` do C13 · ~2h eng · [plano](plans/escala-dry-pos-c6.md)
+- ~~**C8 F4b**~~ ✓ (entregue 2026-07-27) o guard de `formActions` só inspecionava arquivos chamados exatamente `formActions.ts` — ampliado para `*FormActions.ts` + `taskActions.ts`; 6 irmãos migrados para `runCampaignFormAction`/`runCampaignRedirectFormAction`; `taskActions.ts` allowlisted (contrato posicional) · achado do `/simplify` do C13 · [plano](plans/escala-dry-pos-c6.md)
- **B33+** escala/DRY pós-B33 — `StateDeputyFilters` é o **3º call site** do mesmo scaffold de navegação (`SEARCH_DEBOUNCE_MS`, debounce/cleanup, `navigateTo`/`scheduleSearch`) que `TerritoryFilters` (B21 ✓) e `MunicipalityFilters` (B16 ✓) já tinham, achado do `/simplify` do B33 — extrai `useCampaignListFilterNavigation` em `shared/`, sem tocar o JSX/markup de cada filtro nem o contrato de URL · ~0,5d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-b33.md)
- Higiene PascalCase de componentes legados
diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/lifecycleFormActions.ts b/src/app/(campaign)/campanha/(app)/atividades/[slug]/lifecycleFormActions.ts
index e4eba3a0..401cbd29 100644
--- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/lifecycleFormActions.ts
+++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/lifecycleFormActions.ts
@@ -1,44 +1,42 @@
'use server'
-import { redirect } from 'next/navigation'
-
import { cancelActivity, markActivityRealized } from '@/app/(campaign)/campanha/actions/activity'
-import { requiredRelationshipFormValue } from '@/lib/formData'
+import { FormDataBoundaryError, requiredRelationshipFormValue } from '@/lib/formData'
import type { Activity } from '@/payload-types'
+import {
+ runCampaignRedirectFormAction,
+ type CampaignFormActionState,
+} from '@/utilities/campaignFormActionError'
-export type ActivityLifecycleFormState = {
- message?: string
-}
+const ACTIVITY_ID_MISSING_MESSAGE =
+ 'Não foi possível identificar a atividade. Atualize a página e tente novamente.'
+// Parse the id before the redirect ladder so a missing/invalid hidden field
+// returns `message` (ActivityLifecycleDialog only reads that — not fieldErrors).
const setActivityLifecycleFormAction = async (
formData: FormData,
mutate: (activityId: number) => Promise>,
failureMessage: string,
-): Promise => {
+): Promise => {
let activityId: number
try {
activityId = requiredRelationshipFormValue(formData, 'id')
- } catch {
- return {
- message: 'Não foi possível identificar a atividade. Atualize a página e tente novamente.',
- }
+ } catch (error) {
+ if (!(error instanceof FormDataBoundaryError)) throw error
+ return { message: ACTIVITY_ID_MISSING_MESSAGE }
}
- let activitySlug: string
- try {
- const activity = await mutate(activityId)
- activitySlug = activity.slug
- } catch {
- return { message: failureMessage }
- }
-
- redirect(`/campanha/atividades/${activitySlug}`)
+ return runCampaignRedirectFormAction({
+ execute: () => mutate(activityId),
+ redirectTo: (activity) => `/campanha/atividades/${activity.slug}`,
+ genericMessage: failureMessage,
+ })
}
export const cancelActivityFormAction = async (
- _state: ActivityLifecycleFormState,
+ _state: CampaignFormActionState,
formData: FormData,
-): Promise =>
+): Promise =>
setActivityLifecycleFormAction(
formData,
cancelActivity,
@@ -46,9 +44,9 @@ export const cancelActivityFormAction = async (
)
export const markActivityRealizedFormAction = async (
- _state: ActivityLifecycleFormState,
+ _state: CampaignFormActionState,
formData: FormData,
-): Promise =>
+): Promise =>
setActivityLifecycleFormAction(
formData,
markActivityRealized,
diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/resultFormActions.ts b/src/app/(campaign)/campanha/(app)/atividades/[slug]/resultFormActions.ts
index fe5ffd8c..fe8cf211 100644
--- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/resultFormActions.ts
+++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/resultFormActions.ts
@@ -5,7 +5,7 @@ import { revalidatePath } from 'next/cache'
import { registerActivityResultAction } from '@/app/(campaign)/campanha/actions/activity'
import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import {
- mapCampaignFormActionError,
+ runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
@@ -21,20 +21,17 @@ const safeMessages = [
export const registerActivityResultFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
-): Promise => {
- try {
- const activityId = requiredRelationshipFormValue(formData, 'activityId')
- const resultSummary = requiredFormText(formData, 'resultSummary')
- // Media upload is a follow-up: the v1 form submits text only.
- await registerActivityResultAction(activityId, resultSummary, [])
- revalidatePath('/campanha/atividades/[slug]', 'page')
- return { status: 'success', message: 'Resultado registrado com sucesso.' }
- } catch (error) {
- return mapCampaignFormActionError({
- error,
- safeMessages,
- genericMessage:
- 'Não foi possível registrar o resultado. Verifique seu acesso e tente novamente.',
- })
- }
-}
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ const activityId = requiredRelationshipFormValue(formData, 'activityId')
+ const resultSummary = requiredFormText(formData, 'resultSummary')
+ // Media upload is a follow-up: the v1 form submits text only.
+ await registerActivityResultAction(activityId, resultSummary, [])
+ revalidatePath('/campanha/atividades/[slug]', 'page')
+ return { message: 'Resultado registrado com sucesso.' }
+ },
+ safeMessages,
+ genericMessage:
+ 'Não foi possível registrar o resultado. Verifique seu acesso e tente novamente.',
+ })
diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/updateFormActions.ts b/src/app/(campaign)/campanha/(app)/atividades/[slug]/updateFormActions.ts
index 56804e40..fb9cd4d1 100644
--- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/updateFormActions.ts
+++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/updateFormActions.ts
@@ -4,13 +4,10 @@ import { revalidatePath } from 'next/cache'
import { appendActivityUpdate } from '@/app/(campaign)/campanha/actions/activity'
import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
-import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
-
-export type ActivityUpdateFormState = {
- status?: 'success'
- message?: string
- fieldErrors?: Record
-}
+import {
+ runCampaignFormAction,
+ type CampaignFormActionState,
+} from '@/utilities/campaignFormActionError'
// The specific validation/transaction messages `appendActivityUpdate` throws —
// safe to surface verbatim. Anything else (e.g. an unexpected Payload API error)
@@ -22,21 +19,18 @@ const safeMessages = [
] as const
export const createActivityUpdateFormAction = async (
- _state: ActivityUpdateFormState,
+ _state: CampaignFormActionState,
formData: FormData,
-): Promise => {
- try {
- const activityId = requiredRelationshipFormValue(formData, 'activityId')
- const body = requiredFormText(formData, 'body')
- await appendActivityUpdate(activityId, body)
- revalidatePath('/campanha/atividades/[slug]', 'page')
- return { status: 'success', message: 'Atualização registrada com sucesso.' }
- } catch (error) {
- return mapCampaignFormActionError({
- error,
- safeMessages,
- genericMessage:
- 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
- })
- }
-}
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ const activityId = requiredRelationshipFormValue(formData, 'activityId')
+ const body = requiredFormText(formData, 'body')
+ await appendActivityUpdate(activityId, body)
+ revalidatePath('/campanha/atividades/[slug]', 'page')
+ return { message: 'Atualização registrada com sucesso.' }
+ },
+ safeMessages,
+ genericMessage:
+ 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
+ })
diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/pledgeFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/[slug]/pledgeFormActions.ts
index c1b469db..dcaf37e7 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/pledgeFormActions.ts
+++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/pledgeFormActions.ts
@@ -10,7 +10,7 @@ import {
voteEstimateScenarioFromForm,
} from '@/lib/formData'
import {
- mapCampaignFormActionError,
+ runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
@@ -23,50 +23,44 @@ const declareSafeMessages = [
export const declareVotesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
-): Promise => {
- try {
- const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
- const leadership = requiredRelationshipFormValue(formData, 'leadershipId')
- const declaredVotes = requiredIntegerFormValue(formData, 'declaredVotes', {
- minimum: 0,
- maximum: 1_000_000,
- })
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
+ const leadership = requiredRelationshipFormValue(formData, 'leadershipId')
+ const declaredVotes = requiredIntegerFormValue(formData, 'declaredVotes', {
+ minimum: 0,
+ maximum: 1_000_000,
+ })
- await declareVotes({ municipality, leadership, declaredVotes })
- revalidatePath('/campanha/municipios/[slug]', 'page')
- return { status: 'success', message: 'Declaração de votos registrada.' }
- } catch (error) {
- return mapCampaignFormActionError({
- error,
- safeMessages: declareSafeMessages,
- genericMessage:
- 'Não foi possível registrar a declaração. Verifique seu acesso e tente novamente.',
- })
- }
-}
+ await declareVotes({ municipality, leadership, declaredVotes })
+ revalidatePath('/campanha/municipios/[slug]', 'page')
+ return { message: 'Declaração de votos registrada.' }
+ },
+ safeMessages: declareSafeMessages,
+ genericMessage:
+ 'Não foi possível registrar a declaração. Verifique seu acesso e tente novamente.',
+ })
export const estimateVotesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
-): Promise => {
- try {
- const pledge = requiredRelationshipFormValue(formData, 'pledgeId')
- const estimatedVotes = voteEstimateScenarioFromForm(formData, 'estimatedVotes')
- const estimateNote = nullableFormText(formData, 'estimateNote')
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ const pledge = requiredRelationshipFormValue(formData, 'pledgeId')
+ const estimatedVotes = voteEstimateScenarioFromForm(formData, 'estimatedVotes')
+ const estimateNote = nullableFormText(formData, 'estimateNote')
- await estimateVotes({
- pledge,
- estimatedVotes,
- estimateNote,
- })
- revalidatePath('/campanha/municipios/[slug]', 'page')
- return { status: 'success', message: 'Estimativa registrada.' }
- } catch (error) {
- return mapCampaignFormActionError({
- error,
- safeMessages: ['Somente a coordenação e a assessoria registram estimativas.'],
- genericMessage:
- 'Não foi possível registrar a estimativa. Verifique seu acesso e tente novamente.',
- })
- }
-}
+ await estimateVotes({
+ pledge,
+ estimatedVotes,
+ estimateNote,
+ })
+ revalidatePath('/campanha/municipios/[slug]', 'page')
+ return { message: 'Estimativa registrada.' }
+ },
+ safeMessages: ['Somente a coordenação e a assessoria registram estimativas.'],
+ genericMessage:
+ 'Não foi possível registrar a estimativa. Verifique seu acesso e tente novamente.',
+ })
diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts
index 4abadc9d..410a2c1f 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts
+++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts
@@ -16,41 +16,38 @@ import {
type MunicipalityUpdateKind,
} from '@/lib/schemas/municipalityUpdate'
import {
- mapCampaignFormActionError,
+ runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const createMunicipalityUpdateFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
-): Promise => {
- try {
- const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
- const rawKind = requiredFormText(formData, 'kind')
- const kind = municipalityUpdateKinds.includes(rawKind as MunicipalityUpdateKind)
- ? (rawKind as MunicipalityUpdateKind)
- : 'semanal'
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
+ const rawKind = requiredFormText(formData, 'kind')
+ const kind = municipalityUpdateKinds.includes(rawKind as MunicipalityUpdateKind)
+ ? (rawKind as MunicipalityUpdateKind)
+ : 'semanal'
- await createMunicipalityUpdate({
- municipality,
- kind,
- worked: optionalFormText(formData, 'worked'),
- failed: optionalFormText(formData, 'failed'),
- needs: optionalFormText(formData, 'needs'),
- body: optionalFormText(formData, 'body'),
- activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
- newSupports: optionalIntegerFormValue(formData, 'newSupports'),
- signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
- signalSource: optionalFormText(formData, 'signalSource'),
- triangulated: checkboxFormValue(formData, 'triangulated'),
- })
- revalidatePath('/campanha/municipios/[slug]', 'page')
- return { status: 'success', message: 'Atualização registrada com sucesso.' }
- } catch (error) {
- return mapCampaignFormActionError({
- error,
- genericMessage:
- 'Não foi possível registrar a atualização. Verifique seu acesso e tente novamente.',
- })
- }
-}
+ await createMunicipalityUpdate({
+ municipality,
+ kind,
+ worked: optionalFormText(formData, 'worked'),
+ failed: optionalFormText(formData, 'failed'),
+ needs: optionalFormText(formData, 'needs'),
+ body: optionalFormText(formData, 'body'),
+ activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
+ newSupports: optionalIntegerFormValue(formData, 'newSupports'),
+ signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
+ signalSource: optionalFormText(formData, 'signalSource'),
+ triangulated: checkboxFormValue(formData, 'triangulated'),
+ })
+ revalidatePath('/campanha/municipios/[slug]', 'page')
+ return { message: 'Atualização registrada com sucesso.' }
+ },
+ genericMessage:
+ 'Não foi possível registrar a atualização. Verifique seu acesso e tente novamente.',
+ })
diff --git a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts
index 35bdfa38..d90feb9b 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts
+++ b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts
@@ -21,7 +21,7 @@ import {
} from '@/lib/schemas/municipality'
import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import {
- mapCampaignFormActionError,
+ runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation'
@@ -31,91 +31,79 @@ import { municipalityStaffEditSafeMessages } from './municipalityStaffEditMessag
export const setMunicipalityExpectedVotesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
-): Promise => {
- try {
- const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
- const expectedVotes = voteEstimateScenarioFromForm(formData, 'expectedVotes')
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
+ const expectedVotes = voteEstimateScenarioFromForm(formData, 'expectedVotes')
- await setMunicipalityExpectedVotes({ municipality, expectedVotes })
- revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
- return { status: 'success', message: 'Votos estimados atualizados.' }
- } catch (error) {
- return mapCampaignFormActionError({
- error,
- safeMessages: municipalityStaffEditSafeMessages,
- genericMessage:
- 'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.',
- })
- }
-}
+ await setMunicipalityExpectedVotes({ municipality, expectedVotes })
+ revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
+ return { message: 'Votos estimados atualizados.' }
+ },
+ safeMessages: municipalityStaffEditSafeMessages,
+ genericMessage:
+ 'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.',
+ })
export const setMunicipalityPoliticalTrendFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
-): Promise => {
- try {
- const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
- const status = parsePoliticalTrendStatusFormValue(optionalFormText(formData, 'trendStatus'))
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
+ const status = parsePoliticalTrendStatusFormValue(optionalFormText(formData, 'trendStatus'))
- await setMunicipalityPoliticalTrend({
- municipality,
- status,
- note: nullableFormText(formData, 'trendNote'),
- })
- revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
- return { status: 'success', message: 'Tendência política registrada.' }
- } catch (error) {
- return mapCampaignFormActionError({
- error,
- safeMessages: municipalityStaffEditSafeMessages,
- genericMessage:
- 'Não foi possível registrar a tendência. Verifique seu acesso e tente novamente.',
- })
- }
-}
+ await setMunicipalityPoliticalTrend({
+ municipality,
+ status,
+ note: nullableFormText(formData, 'trendNote'),
+ })
+ revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
+ return { message: 'Tendência política registrada.' }
+ },
+ safeMessages: municipalityStaffEditSafeMessages,
+ genericMessage:
+ 'Não foi possível registrar a tendência. Verifique seu acesso e tente novamente.',
+ })
export const assignMunicipalityAdvisorsFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
-): Promise => {
- try {
- const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
- const advisors = repeatedRelationshipFormValues(formData, 'advisors')
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
+ const advisors = repeatedRelationshipFormValues(formData, 'advisors')
- await assignMunicipalityAdvisors({ municipality, advisors })
- revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
- return { status: 'success', message: 'Assessores atualizados.' }
- } catch (error) {
- return mapCampaignFormActionError({
- error,
- safeMessages: [MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE],
- genericMessage:
- 'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
- })
- }
-}
+ await assignMunicipalityAdvisors({ municipality, advisors })
+ revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
+ return { message: 'Assessores atualizados.' }
+ },
+ safeMessages: [MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE],
+ genericMessage:
+ 'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
+ })
export const createMunicipalityListSignalFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
-): Promise => {
- try {
- const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
- await createMunicipalityUpdate({
- municipality,
- kind: 'sinal',
- body: optionalFormText(formData, 'body'),
- signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
- signalSource: optionalFormText(formData, 'signalSource'),
- triangulated: checkboxFormValue(formData, 'triangulated'),
- })
- revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
- return { status: 'success', message: 'Sinal registrado.' }
- } catch (error) {
- return mapCampaignFormActionError({
- error,
- genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
- })
- }
-}
+ await createMunicipalityUpdate({
+ municipality,
+ kind: 'sinal',
+ body: optionalFormText(formData, 'body'),
+ signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
+ signalSource: optionalFormText(formData, 'signalSource'),
+ triangulated: checkboxFormValue(formData, 'triangulated'),
+ })
+ revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
+ return { message: 'Sinal registrado.' }
+ },
+ genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
+ })
diff --git a/src/utilities/campaignFormActionError.ts b/src/utilities/campaignFormActionError.ts
index 0efad552..1413928b 100644
--- a/src/utilities/campaignFormActionError.ts
+++ b/src/utilities/campaignFormActionError.ts
@@ -57,10 +57,10 @@ export const mapCampaignFormActionError = (
* success state (`message` plus extras like created ids). Pre-parsed `values`
* and `revision` are echoed on failure so forms can repopulate.
*
- * Documented exceptions that deliberately stay hand-rolled: the atividades
- * ladders (custom unique-violation mapping + async duplicate-title fallback)
- * and apoiadores/[id] (flattens field errors into message-only states for
- * its inline controls).
+ * Documented exceptions that deliberately stay hand-rolled:
+ * `atividades/formActions.ts` (custom unique-violation mapping + async
+ * duplicate-title fallback) and `apoiadores/[id]/formActions.ts` (flattens
+ * field errors into message-only states for its inline controls).
*/
export const runCampaignFormAction = async <
Success extends { message: string },
diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts
index 0d11346d..8a608161 100644
--- a/tests/unit/codebaseConventions.unit.spec.ts
+++ b/tests/unit/codebaseConventions.unit.spec.ts
@@ -1,5 +1,5 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
-import { relative, resolve } from 'node:path'
+import { basename, relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
@@ -132,21 +132,37 @@ describe('eslint legacy ignore lists', () => {
})
describe('campaign formActions convention', () => {
- // Every route-level formActions.ts goes through the shared wrappers
- // (runCampaignFormAction / runCampaignRedirectFormAction) so error mapping
- // and revalidation cannot drift per route (Pass 2 W4d). A file that truly
- // cannot use them gets an allowlist entry documenting why:
- const allowlist = new Set([])
+ // Every route-level *FormActions.ts (and the known sibling taskActions.ts)
+ // goes through the shared wrappers (runCampaignFormAction /
+ // runCampaignRedirectFormAction) so error mapping and revalidation cannot
+ // drift per route (Pass 2 W4d / C8 F4b). A file that truly cannot use them
+ // gets an allowlist entry documenting why:
+ const isGuardedActionFile = (name: string): boolean =>
+ /^\w*[Ff]ormActions\.ts$/.test(name) || name === 'taskActions.ts'
- it('routes every formActions.ts through a shared wrapper', () => {
+ const allowlist = new Set([
+ // Positional (activityId, taskId, done) => Promise<{ok, message}> consumed
+ // by useOptimistic in ActivityTaskChecklist — not a (state, formData) ladder,
+ // nothing to route through the shared wrapper.
+ 'src/app/(campaign)/campanha/(app)/atividades/[slug]/taskActions.ts',
+ // Custom unique-violation → fieldErrors + async duplicate-title fallback
+ // that links to the existing activity — policy the wrappers don't grow for.
+ 'src/app/(campaign)/campanha/(app)/atividades/formActions.ts',
+ // Flattens field errors into message-only states for inline detail controls.
+ 'src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts',
+ ])
+
+ it('routes every *FormActions / taskActions ladder through a shared wrapper', () => {
const offenders = walkSourceFiles(resolve(repoRoot, 'src/app/(campaign)'), ['.ts'])
- .filter((file) => file.endsWith('/formActions.ts'))
+ .filter((file) => isGuardedActionFile(basename(file)))
.map(repoPath)
.filter((path) => !allowlist.has(path))
- .filter(
- (path) =>
- !/runCampaign(Redirect)?FormAction/.test(readFileSync(resolve(repoRoot, path), 'utf8')),
- )
+ .filter((path) => {
+ // Require a real call site — a comment naming the wrappers must not pass
+ // (the documented exceptions above used to hide behind that loophole).
+ const source = readFileSync(resolve(repoRoot, path), 'utf8')
+ return !/\brunCampaign(Redirect)?FormAction\s*\(/.test(source)
+ })
expect(offenders, 'wrap with runCampaignFormAction/runCampaignRedirectFormAction').toEqual([])
})