` (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).
+- **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).>>>>>>> a178c5e (C8 F4b: widen formActions guard and migrate sibling ladders.)
- **B8 F2 entregue em código (2026-07-26):** as 19 ZE de Salvador ganharam polígono próprio e o coroplético deixou de ser keyed por `codarea`. Antes, Salvador era **um** polígono carregando os votos da cidade inteira, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu" — a única visão por zona era a lista textual do `zoneBreakdown`. **Fase 0 (prep S1+S3):** `municipalityZoneNeighborhoods` passou a autorar só `{ municipalitySlug, source, neighborhoods }` e hidrata `city`/`zoneNumber` de `getMunicipalityCatalogEntry` no `entry()` (slug desconhecido ou não-`zona` falha no import); int novo de ordem de slugs vs catálogo. S2 (gerar TS do JSON) segue cortado — a curadoria ficou no script, não no catálogo. **Fase 1–2 (artefato):** `scripts/build-municipality-zone-geometries.mjs` (`pnpm build:municipality-zone-geometries`, `--report` só reconcilia) baixa/cacheia `BA_bairros_CD2022.zip` do geoftp do IBGE, parseia com **`shpjs`** (devDependency nova — reprojeta o SIRGAS 2000, o IBGE não publica GeoJSON de bairro), filtra `CD_MUN === '2927408'` (**170 polígonos**) e dissolve por zona pela RA 02/2017 do TRE-BA → `src/lib/geometries/bahia-municipality-zones.topo.json` (**72.310 bytes**, ~20 KB gzip, 19 features keyed por `municipalitySlug`), com submódulo `bahiaMunicipalityZoneGeometries.ts` + `loadMunicipalityZoneGeometryModule()` no padrão memoizado de B5 F1. **Reconciliação = 3 tabelas curadas no cabeçalho do script, não regex mais esperta:** `IBGE_TO_TRE_NEIGHBORHOOD` (7 aliases — compostos "Beiru/Tancredo Neves", CAB, ilhas), `TRE_NEIGHBORHOODS_MERGED_INTO` (Jardim Cruzeiro dentro de Vila Ruy Barbosa; o build **recusa** um merge que atravesse zonas) e `SPLIT_NEIGHBORHOOD_ZONES` (Periperi, que a resolução corta na Rua das Pedrinhas, vai **inteiro** para a ZE 17 — o bairro é o átomo geométrico). Atribuição em dois passes: nome da resolução, depois **maior fronteira compartilhada** para os 5 bairros que a RA nunca nomeia (Ilha Amarela, Dois de Julho, Chame-Chame, Horto Florestal, Mirantes de Periperi) — a regra foi **validada** contra os 4 que a resolução nomeia sob outro rótulo e reproduz a zona oficial nos 4. O build morre se sobrar polígono órfão ou bairro TRE sem polígono (hoje 170/170 → 19/19). **Fase 3 (re-keying):** map key = `kind === 'zona' ? slug : ibgeCode` (`mapKeyForMunicipality` + `buildMunicipalitiesByMapKey` em `municipalityMapNavigation.ts`); o bundle inteiro re-keyed (`territorialClassByMapKey`, `projectedValidVotesByMapKey`, `municipalitiesByMapKey`, `valuesByYear`, `values2026ByScenario`, `validVotesByYear`, `diffByYear`); os rollups pararam de acumular com `+=` porque cada map key agora é 1:1; `computeAggregateTerritorialClass` deu lugar a `computeMunicipalityTerritorialClass` (um slug por chave); `resolveMunicipalityMapNavigation` virou 1:1 e o ramo `kind: 'zones'` morreu junto com o scroll-anchor e a copy "toque de novo" do readout. **`competitiveRank` continua por cidade** (o artefato TSE só tem `federalRankByIbgeCode`): as 19 zonas dividem a posição da cidade, o que a legenda já afirmava — int novo pina que as 19 têm **uma** posição só. Per-ZE segue fora (gatilho: produto pedir posição dentro da zona). `buildMunicipalitiesByIbgeCode` sobrevive intacto — o href de lista do B14 ✓ é por cidade. **Fase 4 (mapa):** `BahiaMap` monta a camada de município a partir de **duas** malhas via `loadLayerFeatures`, e o polígono municipal de Salvador fica desenhado **por baixo** como base sem dado e sem pointer (`baseKeys`), para que um sliver de desacordo entre duas malhas simplificadas independentemente não abra buraco no contorno do estado; `keyPropertyForMode`/`featureKeyFromProperties` deram lugar a `featureMapKey` (`municipalitySlug ?? codarea ?? code` — as três nunca coexistem, então o argumento de modo sumiu); `fitMapToHighlights` passou a medir os **paths desenhados** (`pathByKeyRef`) em vez do módulo de geometria, que já não sabe sozinho de qual malha veio a chave, e `geometryModuleRef` foi apagado. Copy do painel: a linha morta "Zonas não têm polígono oficial — Salvador e **Camaçari** aparecem agregadas" (Camaçari saiu do modelo de zona na M1) virou o disclaimer de aproximação. **Dois fatos medidos, para ninguém re-derivar:** o polígono municipal de Salvador tem **~690 km²** e a soma das 19 zonas **~324 km²** — a diferença é a água da baía que os limites municipais encerram e a malha de bairros não desenha, então o int de área compara contra a área de **terra** (280–360 km²) e não contra o município; e centróide-dentro-de-outra-zona **não** é teste de sobreposição válido aqui, porque a ZE 16 é concava e envolve a ZE 14 (o centróide da 16 cai na 14) — a garantia de não-sobreposição é estrutural (cada bairro é atribuído a exatamente uma zona) mais a soma de áreas. Testes: int de contrato (19 features, slugs == catálogo, budget 600 KB, centróide de cada zona dentro do polígono municipal), unit de `featureMapKey`, e2e `campaignZoneMap.e2e.spec.ts` (436 paths / 435 interativos — o único não-interativo é a base de Salvador — readout "Salvador — ZE N" e clique abrindo `/campanha/municipios/salvador-ze-N`). **`/simplify`:** comentário de `NearbyMunicipalityResolution.zoneCity` que dizia "sem polígonos de zona (B8 F2)" corrigido (os polígonos passaram a existir nesta entrega; o card **deliberadamente** continua na lista filtrada — débito aberto, não efeito colateral); duas asserções do int de bairros que viraram tautologia com a hidratação da Fase 0 (comparavam `city`/`zoneNumber` com o catálogo de onde foram copiados) removidas, apontando para o teste de fixture que checa contra a transcrição independente; e o script parou de re-indexar geometrias simplificadas por **nome** de bairro (o irmão `build-bahia-geometries.mjs` usa `codarea`, único por construção) — hoje são 170 nomes distintos em 170 polígonos, mas um nome repetido em malha futura colapsaria dois polígonos silenciosamente num artefato commitado; passou a haver `id` próprio, e o rebuild saiu **byte-idêntico** (sha256 `1dd0d629…`). Sem migration, sem collection, sem action, sem Consent. **Gate:** tsc/lint/format/knip/`check:cycles`/unit+int/e2e/build rodados; **Aikido não fechou** — o scanner MCP responde Opengrep exit code 2 em **qualquer** entrada (inclusive um arquivo-probe trivial de uma linha), então os "4 findings" que ele reportou são artefato da execução quebrada e não achados atribuíveis; o feed do Aikido não tem nada deste repo. SCA do `shpjs` também pendente. **Relatórios do `/simplify` chegaram depois do merge** e o que sobrou virou o fill-in **B8+** ([`escala-dry-pos-b8f2.md`](../../../docs/plans/escala-dry-pos-b8f2.md)), aberto por uma medição que contradiz a intuição de "malha pequena, artefato pequeno": as 19 zonas têm **9.120** pontos de arco contra **9.139** da malha de **todos** os 417 municípios da Bahia (480 vs 22 pontos/feature; Salvador é um `Polygon` de **35** pontos na malha municipal). A causa não é a malha de bairros ser detalhada — é que `QUANTIZE_DIGITS = 1e4` e `SIMPLIFY_QUANTILE = 0.35` foram copiados verbatim do script irmão e **os dois são relativos à própria topologia**: a quantização é uma grade sobre o bbox do arquivo, e o de Salvador é ~26× mais estreito, o que dá **~3,9 m** de resolução contra ~103 m no estado, num mapa que para em `maxZoom: 10`. Consequência para quem for consertar: o que deve ficar compartilhado entre os dois scripts é a **escala de chão**, não a contagem de dígitos — extrair as constantes como estão (o que o revisor de reuse pediu, sem essa medição) cimentaria o defeito e lhe daria autoridade de política. Os outros três: nada roda `featureMapKey` sobre um feature **real** fora do e2e, então as três escritas da map key podem divergir e pintar cinza sem erro em lugar nenhum; o `Promise.all` das duas malhas somado à memoização da rejeição fez de um chunk de 19 polígonos um ponto único de falha do mapa inteiro (antes a malha municipal sozinha bastava); e a ressalva "é aproximado, não é o limite oficial do TSE" está pendurada na condição de render da **lista** (`!comparisonActive`), então desaparece no modo comparação enquanto a malha aproximada continua pintada. Plano: [`poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md).
- **B39–B42 registrados (2026-07-26, lote roadmap-items):** sessão "Lembrar de mim" (**B39**, ~0,5d, não cortável — default hoje 2 h); login biométrico WebAuthn (**B40**, soft B39, cortável); scroll horizontal + sticky coluna Município (**B41**, espelha TerritoryList/B21); polimento mobile lista municípios (**B42** — Drawer Tendência/Assessores no padrão B26 + card compacto). Planos: `lembrar-de-mim-sessao-campanha.md`, `login-biometrico-campanha.md`, `scroll-horizontal-lista-municipios.md`, `polimento-mobile-lista-municipios.md`. Dedup do lote: drawers + card mobile mesclados em B42; auth fatiado B39/B40.
- **E12 entregue em código (2026-07-26):** camada TI — rollups MAUP + benchmark intra-TI. Estende `territoryOverview.ts` (`computeTerritoryE12Rollup`: cobertura agregada E8 cenário `central`, captura regional como razão dos agregados, mediana/amplitude, município crítico e farol) e `loadTerritoryOverview.ts` (passa `user`, `loadMunicipalityGoalCoverageBundle`, `computeAggregateTerritorialClass` por TI e sub-linhas do Metropolitano). `/campanha/territorios`: colunas Cobertura/Captura/Classe, sorts e descrições B22 em `territoryListUrl.ts` / `territoryListLabels.ts` / `TerritoryListColumns.tsx`. Detalhe: `territoryIntraCaptureBenchmark.ts` + linha T4 no `MunicipalityGoalAccountCard`. Conceitos E18 `captura-regional` e `benchmark-intra-ti`. Cortes v1: sem agrupamento na lista de municípios, sem mapa TI. `sanityCheckSuggestedGoalsByTerritory` segue sem UI. Gate: tsc/lint/format/check:cycles/533 unit/406 int/build (com `PAYLOAD_SECRET` local); knip P3 pré-existente em `payload.config.ts`; Aikido 0 findings. Plano: [`camada-territorios-identidade.md`](../../../docs/plans/camada-territorios-identidade.md).
diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md
index 4ca091cf..9a22dd24 100644
--- a/docs/TECH-DEBT.md
+++ b/docs/TECH-DEBT.md
@@ -34,7 +34,7 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| 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) |
+| 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) |
diff --git a/docs/plans/escala-dry-pos-c6.md b/docs/plans/escala-dry-pos-c6.md
index b3a58ebd..a18d7da8 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,17 @@ 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: `atividades/[slug]/taskActions.ts` (contrato posicional `Promise<{ok,message}>` para `useOptimistic`, não é ladder `(state, formData)`).
+- Docstring de `runCampaignFormAction` precisada: a exceção de atividades é só `atividades/formActions.ts` (unique-violation + duplicate-title), não os irmãos.
+
+**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..f05bf0b0 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,7 @@
# 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).
+>>>>>>> a178c5e (C8 F4b: widen formActions guard and migrate sibling ladders.)
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
@@ -143,7 +144,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..cba0b000 100644
--- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/lifecycleFormActions.ts
+++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/lifecycleFormActions.ts
@@ -1,44 +1,41 @@
'use server'
-import { redirect } from 'next/navigation'
-
import { cancelActivity, markActivityRealized } from '@/app/(campaign)/campanha/actions/activity'
import { 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.',
- }
+ 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 +43,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..32a1e567 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,15 +132,24 @@ 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',
+ ])
+
+ 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(