diff --git a/docs/plans/escala-dry-pos-c6.md b/docs/plans/escala-dry-pos-c6.md
new file mode 100644
index 00000000..f03b1a51
--- /dev/null
+++ b/docs/plans/escala-dry-pos-c6.md
@@ -0,0 +1,120 @@
+# Escala e DRY pós-C6 (apoiadores / import / listas)
+
+Status: rascunho
+Atualizado em: 2026-07-19
+Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C8)
+Responsável: —
+
+## Contexto
+
+O C6 ([escala-dry-pos-c2.md](escala-dry-pos-c2.md)) entregou a escala do cadastro nominal de apoiadores: import bulk via drizzle na txn Payload, KPI agregado em SQL, token HMAC de import single-use, e shells compartilhados (`campaignListUrl`, `CampaignListPagination`, `campaignFormFields`, `mapCampaignFormActionError`). A passagem `/simplify` de 2026-07-19 aplicou limpezas pontuais (identity maps, dead branches, `relationshipId`, base64url nativo, `rowCount ?? 0`, reuso de `canManageCampaignUsers`) mas **deixou de fora** débitos que os revisores (quality / reuse / performance) marcaram como importantes e maiores que cleanup. Este plano é o registro canônico desses follow-ups — precedente direto de B5 (pós-B2) e C7 (pós-C3).
+
+Sem este item, a importação funciona em volume baixo, mas: (a) o gargalo de import em escala volta a ser a aquisição de locks advisory (um round-trip por telefone), não o insert bulk; (b) a página de lista de apoiadores faz dois COUNTs filtrados por navegação; (c) a busca por nome/cidade é `ILIKE '%q%'` não-sargable; (d) o scaffold de tipos drizzle do bulk apaga a tipagem de colunas (já causou um bug `contactId` → `contact`); (e) o helper de erro de form adotado só por 2 forms permanece duplicado em ~6 `formActions` legados.
+
+## Objetivos
+
+- Import bulk de 5k apoiadores adquire todos os locks advisory de telefone em **um** round-trip (hoje até 5k round-trips sequenciais).
+- O insert bulk de contacts recupera IDs via `.returning()` (hoje re-`payload.find` por chunk).
+- Leituras de existência (contacts/supporters existentes) no bulk usam drizzle direto na mesma txn, sem o scaffolding do Local API.
+- A página de lista de apoiadores não faz dois COUNTs filtrados por navegação; o `total` do overview vem do `totalDocs` da lista, ou o overview é cacheado por `(user, filterState)` com TTL curto.
+- Busca por `q` em nome/cidade usa índice `pg_trgm` GIN (ou restringe a prefix/telefone) — deixa de ser seq scan por página.
+- O lookup de núcleos acessíveis do coordenador é memoizado por request (lista + overview compartilham uma query).
+- `chunk` / `requireTable` / `INSERT_CHUNK_SIZE` / scaffold de tipos drizzle vivem num helper compartilhado, reusado por `supporterImportBulk.ts` e `electionResultsImport.ts` (import TSE).
+- O scaffold de tipos drizzle não apaga a tipagem de colunas: assertion runtime dos nomes de coluna em `payload.db.tables.supporter` ao init **ou** `PostgresTransactionDatabase` expõe um `insert` tipado.
+- `getAccessibleNucleusIds` ganha uma variante sem `PayloadRequest` (ou `supporterListOverviewAggregate` reusa a existente), eliminando o lookup duplicado de escopo coordenador.
+- Todos os `formActions.ts` de `/campanha` restantes migram para `mapCampaignFormActionError` + `campaignFormFields` (hoje duplicados em ~6 arquivos).
+- Guardrails: `overrideAccess: false` com `user`; escritas multi-step continuam em `withPayloadTransaction`; locks advisory continuam xact-level; sem novo `Consent`; sem mudança de comportamento visível.
+
+## Decisões travadas
+
+- **Um item C8, fases ordenadas.** Mesmo racional do C6/C7: um ID de roadmap, PRs por fase. Ordem: perf do caminho quente (import) → perf de leitura (lista) → DRY de helpers → DRY de forms.
+- **Dependência dura de C6 (merge).** O código que C8 otimiza é o código do C6; não reabre o escopo de v1 do `supporter`/`supporterImportBatch`.
+- **Cortável se a base nominal permanecer pequena.** Fases 1–2 (perf de import/lista) só rendem com base real em volume; se a base ficar pequena até 16/08, adiar para Janela 3. Fases 3–4 (DRY) são baratas e reduzem drift — manter se houver folga.
+- **Sem migration de schema em Fases 1–3.** Fase 2 pode exigir uma migration pequena (`pg_trgm` GIN em `contact.name`/`contact.city`) — única migration do item.
+- **`postgresTransactionLocks.ts` é pré-existente e compartilhado** com o path single-create (`upsertContactByPhone`, `enforceUniqueContactPhone`). A Fase 1 troca o loop de locks por uma query única sem mudar o contrato `acquireContactPhoneLocks(payload, req, phones[])` — callers não mudam.
+- **i18n e naming** (AGENTS.md): identificadores em inglês (`acquireContactPhoneLocks` mantido, `drizzleBulk.ts`, `getAccessibleNucleusIds` variant, etc.), strings visíveis em pt-BR.
+
+## Questões em aberto
+
+- **`pg_trgm` vs prefix-only para busca de nome?** `ILIKE '%q%'` precisa de trigram para ser sargable; prefix-only (`ILIKE 'q%'`) usa btree mas perde busca infix. **Recomendação:** `pg_trgm` GIN (a busca infix é o comportamento esperado pela UI hoje); migration pequena `add_contact_trgm_index`.
+- **Overview `total` derivado do `totalDocs` da lista vs cache?** Derivar elimina um COUNT mas acopla overview ao carregamento da lista (overview some se lista não carrega). **Recomendação:** derivar `total` de `result.totalDocs` (já computado pela lista) e rodar só os dois `FILTER` no overview; cache só se houver reclamação de latency.
+- **Assertion runtime de colunas vs widen `PostgresTransactionDatabase`?** Assertion é barata e pega o bug na inicialização; widen é mais limpo mas toca `postgresTransactionLocks.ts` (pré-existente). **Recomendação:** assertion runtime em `supporterImportBulk.ts` (escopo do item); widen fica como follow-up de plataforma.
+- **Migrar formActions de planos aqui (C8) ou em C7?** O plano do C6 anotou "mapper de form actions de planos ficou para C7". **Recomendação:** consolidar TODOS os formActions restantes em C8 Fase 4 (C8 é o "DRY pós-C6" e C6 introduziu os helpers); C7 Fase 3 continua dono dos helpers FormData/URL de território do actionPlan. Atualizar a nota cruzada no plano do C6 na hora do PR.
+
+## Abordagem proposta
+
+```mermaid
+flowchart TD
+ F1["Fase 1 — Perf import
locks em 1 RT · .returning · drizzle reads"]
+ F2["Fase 2 — Perf lista
1 COUNT · trgm · memo accessible nuclei"]
+ F3["Fase 3 — DRY helpers
drizzleBulk · getAccessibleNucleusIds variant · DrizzleTx assertion"]
+ F4["Fase 4 — DRY forms
migrar formActions restantes"]
+
+ C6["C6 apoiadores ✓"] --> F1
+ F1 --> F2
+ F2 --> F3
+ F3 --> F4
+```
+
+### Fase 1 — Perf do caminho quente de import
+
+- **`acquireContactPhoneLocks`** (`src/utilities/contactPhoneInvariant.ts` / `postgresTransactionLocks.ts`): trocar o loop `for (const key of sortedKeys) await database.execute(...)` por uma query única `SELECT pg_advisory_xact_lock(hashtextextended(k, 0)) FROM unnest(ARRAY[...]) AS k` (ordenar para evitar deadlock). Contrato `acquireContactPhoneLocks(payload, req, phones[])` inalterado — callers (bulk, single-create, hook) não mudam.
+- **`bulkInsertSupporterImport`** (`src/utilities/supporterImportBulk.ts`): `tx.insert(contactTable).values(...).returning({ id: true, phone: true })` em vez do re-`payload.find` por chunk para recuperar IDs (elimina ~10 round-trips no cap de 5k). Widen o tipo `DrizzleTx` local para tipar `.returning()`.
+- Substituir os dois `payload.find` de existência (contacts por phone, supporters por contact_id) por `SELECT ... FROM contact WHERE phone = ANY($)` / `SELECT contact_id FROM supporter WHERE contact_id = ANY($) AND nucleus_id IS NULL` na mesma sessão drizzle da txn.
+
+### Fase 2 — Perf de leitura da lista
+
+- **`loadSupporterListOverviewData`** (`src/utilities/supporterPageData.ts`): derivar `total` do `result.totalDocs` da lista (já computado por Payload); o overview roda só os dois `COUNT(*) FILTER` (certo+tende, indeciso). Elimina um COUNT filtrado completo por navegação.
+- **`computeSupporterListOverviewAggregate`** (`src/utilities/supporterListOverviewAggregate.ts`): memoizar o `resolveAccessConstraint` (lookup de núcleos acessíveis do coordenador) por request — lista e overview compartilham a mesma query `electoralNucleus`.
+- **Migration** `add_contact_trgm_index`: `CREATE EXTENSION IF NOT EXISTS pg_trgm` + `CREATE INDEX ... USING gin (name gin_trgm_ops)` e `(city gin_trgm_ops)` em `contact`. Torna `ILIKE '%q%'` sargable. Única migration do item.
+- Avaliar restringir `q` a mínimo 2–3 chars no cliente (reduz seq scans inúteis) — opcional, sem migration.
+
+### Fase 3 — DRY de helpers
+
+- **`src/utilities/drizzleBulk.ts`** (novo): extrair `chunk`, `requireTable`, `INSERT_CHUNK_SIZE`, e o scaffold de tipos `DrizzleTx`/`PayloadDb` de `supporterImportBulk.ts` e `electionResultsImport.ts` (hoje byte-idênticos). Importar de ambos.
+- **`getAccessibleNucleusIds`** (`src/utilities/campaignAccess.ts`): adicionar variante sem `PayloadRequest` (ou parametrizar a existente com cache opcional) para que `supporterListOverviewAggregate` reuse em vez de re-implementar o lookup `coordinators contains user.id`.
+- **Type safety do bulk:** assertion runtime em `supporterImportBulk.ts` que `Object.keys(payload.db.tables.supporter)` contém as colunas esperadas (`contact`, `nucleus`, `consent`, `createdBy`, …) no init do módulo — pega o bug `contactId` vs `contact` antes do runtime. (Widen de `PostgresTransactionDatabase` fica como follow-up de plataforma.)
+
+### Fase 4 — DRY de forms
+
+- Migrar os `formActions.ts` restantes para `mapCampaignFormActionError` + `campaignFormFields` (hoje o ladder `FormDataBoundaryError` → `ZodError` → `validationFieldErrors` → safe-message está inlined em ~6 arquivos):
+ - `src/app/(campaign)/campanha/(app)/nucleos/formActions.ts`
+ - `src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts`
+ - `src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts`
+ - `src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts`
+ - `src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts`
+ - `src/app/(campaign)/campanha/convite/[token]/formActions.ts`
+ - `src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts`
+ - `src/app/(campaign)/campanha/(app)/planos/formActions.ts` e `planos/[slug]/updateFormActions.ts`
+- Migrar `fieldError`/`firstError` local em `src/components/campaign/NucleusUpdateForm.tsx` e `CampaignInviteForm.tsx` para importar de `campaignFormFields.ts`.
+
+**Migration:** Fases 1, 3, 4 sem schema. Fase 2: `pnpm migrate:create add_contact_trgm_index` (extensão + 2 GIN indexes). Sem Consent novo.
+
+## Dependências
+
+- **Dura:** C6 Escala e DRY pós-C2 (código em `supporterImportBulk.ts` / `supporterListOverviewAggregate.ts` / `supporterPageData.ts` / `campaignFormActionError.ts`).
+- Reusa: `src/utilities/contactPhoneInvariant.ts`, `postgresTransactionLocks.ts`, `getPostgresTransactionDatabase`, `relationshipId`, `campaignAccess.ts` (`getAccessibleNucleusIds`, `canManageCampaignUsers`), `electionResultsImport.ts` (para `drizzleBulk.ts`), C6 plano [escala-dry-pos-c2.md](escala-dry-pos-c2.md).
+
+## Não escopo
+
+- Reabrir decisões de v1 do C6 (token HMAC vs opaque, `supporterImportBatch` como staging, `skipContactPhoneInvariant` fail-closed) — [escala-dry-pos-c2.md](escala-dry-pos-c2.md).
+- Helpers FormData/URL de território do actionPlan — C7 Fase 3 / [escala-dry-pos-c3.md](escala-dry-pos-c3.md).
+- Camada de zonas no mapa, coroplético — B3/B4.
+- Widen de `PostgresTransactionDatabase` para expor `insert` tipado — follow-up de plataforma (a assertion runtime da Fase 3 cobre o bug).
+- Notificações / push — D2.
+
+## Referências
+
+- `docs/roadmap.md` — Trilha C, item C8; sequência Janela 2
+- [escala-dry-pos-c2.md](escala-dry-pos-c2.md) — C6 entregue; precedente do formato
+- [escala-dry-pos-b2.md](escala-dry-pos-b2.md) — precedente B5 (pós-B2 simplify)
+- [escala-dry-pos-c3.md](escala-dry-pos-c3.md) — precedente C7 (pós-C3 simplify)
+- Review `/simplify` 2026-07-19 (quality / reuse / performance) — origem das fases
+- `src/utilities/supporterImportBulk.ts` — bulk insert (locks, .returning, drizzle reads)
+- `src/utilities/postgresTransactionLocks.ts` — `acquireContactPhoneLocks` (loop por telefone)
+- `src/utilities/supporterListOverviewAggregate.ts` — aggregate SQL + `resolveAccessConstraint`
+- `src/utilities/supporterPageData.ts` — `loadSupporterListOverviewData` (duplo COUNT)
+- `src/utilities/electionResultsImport.ts` — `chunk`/`requireTable`/`INSERT_CHUNK_SIZE` duplicados
+- `src/utilities/campaignAccess.ts` — `getAccessibleNucleusIds`
+- `src/utilities/campaignFormActionError.ts` / `campaignFormFields.ts` — helpers adotados por 2 forms
+- AGENTS.md — `overrideAccess: false`, transações, locks advisory xact-level, naming
diff --git a/docs/roadmap.md b/docs/roadmap.md
index bde596e0..dfe13aab 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado (Fases 1–5) — aguarda deploy; C7 registrado — escala/DRY pós-C3; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`)
+Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado (Fases 1–5) — aguarda deploy; C7 registrado — escala/DRY pós-C3; C8 registrado — escala/DRY pós-C6 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`)
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
@@ -86,6 +86,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| C6 Escala e DRY pós-C2 | — (sem design novo; reusa os de C2 + padrões de núcleos) | [escala-dry-pos-c2.md](plans/escala-dry-pos-c2.md) |
| C3 Eventos / agenda de mobilização | `Planos-de-Acao`, `Novo-Plano-de-Acao` | [eventos-agenda-mobilizacao.md](plans/eventos-agenda-mobilizacao.md) |
| C7 Escala e DRY pós-C3 | — (sem design novo; reusa os de C3 + padrões de núcleos/C6) | [escala-dry-pos-c3.md](plans/escala-dry-pos-c3.md) |
+| C8 Escala e DRY pós-C6 | — (sem design novo; reusa os de C2/C6) | [escala-dry-pos-c6.md](plans/escala-dry-pos-c6.md) |
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
| D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) |
@@ -127,6 +128,7 @@ flowchart TD
C6["C6 Escala e DRY pós-C2
(import/listas/forms)"]
C3["C3 Eventos / agenda de mobilização ✓"]
C7["C7 Escala e DRY pós-C3
(território/contato/leituras)"]
+ C8["C8 Escala e DRY pós-C6
(locks bulk / leituras / forms DRY)"]
C4["C4 Demandas"]
C5["C5 Operação dia D / GOTV
(proposto, a validar)"]
end
@@ -160,11 +162,12 @@ flowchart TD
C2 --> C6
C2 --> C5
C6 -.escala da base.-> C5
+ C6 --> C8
D1 --> D2
JUR -.chave de push.-> D2
```
-Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, B2, C1, C2, C3 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A4 (A2/A3 + B1 prontos), B3 (B1 ✓ + B2 ✓; suave: A4 para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C6 (merge de C2 ✓), C7 (após C3; suave: C6 F2) e D2 push (D1 pronto; falta a chave jurídica).
+Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, B2, C1, C2, C3 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A4 (A2/A3 + B1 prontos), B3 (B1 ✓ + B2 ✓; suave: A4 para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C6 (merge de C2 ✓), C7 (após C3; suave: C6 F2), C8 (merge de C6 ✓; cortável se a base nominal permanecer pequena) e D2 push (D1 pronto; falta a chave jurídica).
### Sequência de execução por janela do calendário
@@ -185,29 +188,30 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi
| ----- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- |
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta e mesclada em `main` 2026-07-18 — deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6, C7 |
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) **(implementado 2026-07-19 — Fases 1–5; aguarda deploy)** | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, C7 |
-| 9 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7 |
-| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7 |
-| 11 | C7 Escala e DRY pós-C3 (território/contato compostos, leituras por aba, RMW/índice) | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6 |
+| 9 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7, C8 |
+| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7, C8 |
+| 11 | C7 Escala e DRY pós-C3 (território/contato compostos, leituras por aba, RMW/índice) | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8 |
+| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7 |
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------- |
-| 12 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B3, B5, C4, D1 |
-| 13 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; B5 F1 lazy geometrias) | A5, B5, C4, D1 |
-| 14 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, B3, C4, D1 |
-| 15 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, B5, D1 |
-| 16 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
-| 17 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, B5, C4 |
+| 13 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B3, B5, C4, D1 |
+| 14 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; B5 F1 lazy geometrias) | A5, B5, C4, D1 |
+| 15 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, B3, C4, D1 |
+| 16 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, B5, D1 |
+| 17 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
+| 18 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, B5, C4 |
**Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.**
| Ordem | Item | Plano | Depende de |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
-| 18 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
-| 19 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
-| 20 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
-| 21 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — |
+| 19 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
+| 20 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
+| 21 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
+| 22 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — |
**Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):**
@@ -216,7 +220,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi
- **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
-**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 (leituras/RMW/índice — só se a agenda permanecer pequena), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2 e C7 fases 1–2 (DRY de UI) se houver folga. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
+**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 (leituras/RMW/índice — só se a agenda permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2 e C8 fases 3–4 (DRY de helpers/forms) se houver folga. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
### Itens consolidados/removidos nesta revisão (2026-07-17)
diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts
index 965a7a7d..93f2fded 100644
--- a/src/app/(campaign)/campanha/actions/supporter.ts
+++ b/src/app/(campaign)/campanha/actions/supporter.ts
@@ -32,6 +32,7 @@ import {
verifySupporterImportToken,
} from '@/utilities/supporterImportToken'
import {
+ isPreviewErrorRow,
isSupporterImportOkRow,
type SupporterImportPreviewResult,
type SupporterImportPreviewRow,
@@ -50,18 +51,6 @@ const MAX_IMPORT_ROWS = 5000
/** On-screen preview is capped; the full ok set is staged server-side (Phase 5). */
const SUPPORTER_IMPORT_SAMPLE_SIZE = 100
-const isPreviewErrorRow = (row: SupporterImportPreviewRow): boolean =>
- row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone'
-
-const importStatusCsvValue: Record = {
- ok: 'ok',
- duplicado_pelo_telefone: 'duplicado_pelo_telefone',
- telefone_invalido: 'telefone_invalido',
- municipio_nao_reconhecido: 'municipio_nao_reconhecido',
- nome_invalido: 'nome_invalido',
- intencao_invalida: 'intencao_invalida',
-}
-
const escapeCsvCell = (value: string): string => `"${value.replace(/"/g, '""')}"`
const buildSupporterImportErrorReportCsv = (rows: SupporterImportPreviewRow[]): string => {
@@ -70,7 +59,7 @@ const buildSupporterImportErrorReportCsv = (rows: SupporterImportPreviewRow[]):
.filter(isPreviewErrorRow)
.map(
(row) =>
- `${row.line},${escapeCsvCell(row.nome)},${escapeCsvCell(row.telefone)},${escapeCsvCell(row.municipio)},${escapeCsvCell(row.intencao)},${importStatusCsvValue[row.status]}`,
+ `${row.line},${escapeCsvCell(row.nome)},${escapeCsvCell(row.telefone)},${escapeCsvCell(row.municipio)},${escapeCsvCell(row.intencao)},${row.status}`,
)
.join('\n')
return header + body
@@ -600,12 +589,7 @@ export const confirmSupporterImportRecord = async (
payload,
req,
actorID: currentActor.id,
- rows: batch.okRows.map((row) => ({
- telefone: row.telefone,
- nome: row.nome,
- municipio: row.municipio,
- intencao: row.intencao,
- })),
+ rows: batch.okRows,
registrationConsent,
voteIntentionConsent,
consentNote,
diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts
index 87f7c0e0..3b3ef5f9 100644
--- a/src/collections/Contact.ts
+++ b/src/collections/Contact.ts
@@ -22,7 +22,7 @@ const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({
// honored inside an active transaction — without one the xact-level advisory
// lock cannot be held, so we run the full check regardless of the caller's claim.
if (req.context?.skipContactPhoneInvariant === true) {
- if (req.transactionID === undefined || req.transactionID === null) {
+ if (req.transactionID == null) {
throw new APIError(
'skipContactPhoneInvariant exige uma transação ativa com os locks de telefone já adquiridos.',
500,
diff --git a/src/collections/SupporterImportBatch.ts b/src/collections/SupporterImportBatch.ts
index b4582f3f..c3613a02 100644
--- a/src/collections/SupporterImportBatch.ts
+++ b/src/collections/SupporterImportBatch.ts
@@ -1,6 +1,6 @@
-import type { Access, CollectionConfig } from 'payload'
+import type { CollectionConfig } from 'payload'
-import { getFreshCampaignUser, isCampaignGeneral, isPayloadAdmin } from '@/utilities/campaignAccess'
+import { canManageCampaignUsers } from '@/utilities/campaignAccess'
/**
* Short-lived server-side staging for the supporter CSV import wizard (roadmap
@@ -10,16 +10,11 @@ import { getFreshCampaignUser, isCampaignGeneral, isPayloadAdmin } from '@/utili
* Action boundary twice.
*
* The collection is hidden from the admin nav and restricted to `geral` /
- * Payload-admin; the import actions interact with it via the Local API with
- * `overrideAccess: true`. Rows hold transient supporter PII and must be
- * purged on consume or after `expiresAt` (lazy cleanup + sweep).
+ * Payload-admin (same policy as `campaignUser` management); the import actions
+ * interact with it via the Local API with `overrideAccess: true`. Rows hold
+ * transient supporter PII and must be purged on consume or after `expiresAt`
+ * (lazy cleanup + sweep).
*/
-const canAccessImportBatch: Access = async ({ req }) => {
- if (isPayloadAdmin(req.user)) return true
- const fresh = await getFreshCampaignUser(req)
- return isCampaignGeneral(fresh)
-}
-
export const SupporterImportBatch: CollectionConfig = {
slug: 'supporterImportBatch',
labels: {
@@ -33,10 +28,10 @@ export const SupporterImportBatch: CollectionConfig = {
defaultColumns: ['batchId', 'actor', 'expiresAt'],
},
access: {
- create: canAccessImportBatch,
- read: canAccessImportBatch,
- update: canAccessImportBatch,
- delete: canAccessImportBatch,
+ create: canManageCampaignUsers,
+ read: canManageCampaignUsers,
+ update: canManageCampaignUsers,
+ delete: canManageCampaignUsers,
},
fields: [
{
diff --git a/src/components/campaign/SupporterImportWizard.tsx b/src/components/campaign/SupporterImportWizard.tsx
index e265134b..f32c3699 100644
--- a/src/components/campaign/SupporterImportWizard.tsx
+++ b/src/components/campaign/SupporterImportWizard.tsx
@@ -29,6 +29,7 @@ import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { cn } from '@/lib/utils'
import {
+ isPreviewErrorRow,
type SupporterImportPreviewResult,
type SupporterImportPreviewRow,
} from '@/utilities/supporterImport'
@@ -45,9 +46,6 @@ const importStatusLabels: Record =
const steps = ['Upload', 'Conferir prévia', 'Confirmação'] as const
-const isPreviewErrorRow = (row: SupporterImportPreviewRow): boolean =>
- row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone'
-
const downloadErrorReport = (errorReportCsv: string) => {
const blob = new Blob([errorReportCsv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
@@ -196,7 +194,7 @@ export const SupporterImportWizard = () => {
type="button"
variant="outline"
className="min-h-11"
- onClick={() => preview && downloadErrorReport(preview.errorReportCsv)}
+ onClick={() => downloadErrorReport(preview.errorReportCsv)}
>
Baixar relatório de erros
diff --git a/src/utilities/campaignFormActionError.ts b/src/utilities/campaignFormActionError.ts
index 7d6ec28d..077af937 100644
--- a/src/utilities/campaignFormActionError.ts
+++ b/src/utilities/campaignFormActionError.ts
@@ -30,10 +30,7 @@ export const mapCampaignFormActionError = (
if (error instanceof ZodError) {
return { fieldErrors: validationFieldErrors(error), values, revision }
}
- if (
- error instanceof Error &&
- safeMessages.includes(error.message as (typeof safeMessages)[number])
- ) {
+ if (error instanceof Error && safeMessages.includes(error.message)) {
return { message: error.message, values, revision }
}
return { message: genericMessage, values, revision }
diff --git a/src/utilities/supporterImport.ts b/src/utilities/supporterImport.ts
index df25283e..9a07cc69 100644
--- a/src/utilities/supporterImport.ts
+++ b/src/utilities/supporterImport.ts
@@ -49,3 +49,6 @@ export type SupporterImportPreviewResult = {
export const isSupporterImportOkRow = (
row: SupporterImportPreviewRow,
): row is SupporterImportOkRow => row.status === 'ok'
+
+export const isPreviewErrorRow = (row: SupporterImportPreviewRow): boolean =>
+ row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone'
diff --git a/src/utilities/supporterImportBulk.ts b/src/utilities/supporterImportBulk.ts
index 75b37079..b4ece1e5 100644
--- a/src/utilities/supporterImportBulk.ts
+++ b/src/utilities/supporterImportBulk.ts
@@ -6,6 +6,7 @@ import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import type { ConsentDescriptor } from '@/utilities/campaignConsent'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { getPostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks'
+import { relationshipId } from '@/utilities/relationship'
export type SupporterImportBulkRow = {
telefone: string
@@ -45,7 +46,7 @@ const INSERT_CHUNK_SIZE = 500
const chunk = (rows: readonly T[], size: number): T[][] => {
const out: T[][] = []
- for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size) as T[])
+ for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size))
return out
}
@@ -187,30 +188,21 @@ export const bulkInsertSupporterImport = async (args: {
}
const contactIDs = [...new Set(contactIdByPhone.values())]
- const existingSupporters =
- contactIDs.length > 0
- ? await payload.find({
- collection: 'supporter',
- where: {
- and: [{ contact: { in: contactIDs } }, { nucleus: { exists: false } }],
- },
- depth: 0,
- limit: contactIDs.length,
- pagination: false,
- select: { contact: true },
- overrideAccess: true,
- req,
- })
- : null
+ const existingSupporters = await payload.find({
+ collection: 'supporter',
+ where: {
+ and: [{ contact: { in: contactIDs } }, { nucleus: { exists: false } }],
+ },
+ depth: 0,
+ limit: contactIDs.length,
+ pagination: false,
+ select: { contact: true },
+ overrideAccess: true,
+ req,
+ })
const supporterContactIDs = new Set(
- (existingSupporters?.docs ?? [])
- .map((doc) =>
- typeof doc.contact === 'number'
- ? doc.contact
- : typeof doc.contact === 'object' && doc.contact !== null
- ? doc.contact.id
- : null,
- )
+ existingSupporters.docs
+ .map((doc) => relationshipId(doc.contact))
.filter((id): id is number => id !== null),
)
@@ -236,7 +228,6 @@ export const bulkInsertSupporterImport = async (args: {
let created = 0
for (const batch of chunk(candidateEntries, INSERT_CHUNK_SIZE)) {
- if (batch.length === 0) continue
const result = await tx
.insert(supporterTable)
.values(
@@ -249,7 +240,9 @@ export const bulkInsertSupporterImport = async (args: {
}),
)
.onConflictDoNothing()
- created += Number(result.rowCount ?? batch.length)
+ // rowCount reflects inserted rows only (conflicts are skipped). Fall back to 0
+ // rather than batch.length so a null rowCount never silently over-counts.
+ created += Number(result.rowCount ?? 0)
}
return { created, skipped, errors }
diff --git a/src/utilities/supporterImportToken.ts b/src/utilities/supporterImportToken.ts
index fbb8a5c8..ef62dc77 100644
--- a/src/utilities/supporterImportToken.ts
+++ b/src/utilities/supporterImportToken.ts
@@ -14,7 +14,7 @@ export type SupporterImportOkRow = {
intencao?: SupporterVoteIntention
}
-export type SupporterImportBatch = {
+export type StagedSupporterImportBatch = {
actorID: number
expiresAt: number
okRows: SupporterImportOkRow[]
@@ -27,7 +27,7 @@ const IMPORT_TOKEN_TTL_MS = 10 * 60 * 1000
export const storeSupporterImportBatch = async (
payload: Payload,
batchId: string,
- batch: SupporterImportBatch,
+ batch: StagedSupporterImportBatch,
req?: PayloadTransactionRequest,
): Promise => {
await payload.create({
@@ -49,7 +49,7 @@ export const loadSupporterImportBatch = async (
batchId: string,
expectedActorID: number,
req?: PayloadTransactionRequest,
-): Promise => {
+): Promise => {
const result = await payload.find({
collection: BATCH_COLLECTION,
where: { batchId: { equals: batchId } },
@@ -60,21 +60,15 @@ export const loadSupporterImportBatch = async (
req,
})
const doc = result.docs[0] as
- | { id: number; actor: unknown; expiresAt?: string; rows?: unknown }
+ | { id: number; actor: number; expiresAt: string; rows: unknown }
| undefined
if (!doc) throw new Error('Lote de importação não encontrado ou expirado.')
- const actorID =
- typeof doc.actor === 'number'
- ? doc.actor
- : typeof doc.actor === 'object' && doc.actor !== null && 'id' in doc.actor
- ? (doc.actor as { id: number }).id
- : null
- if (actorID !== expectedActorID) {
+ if (doc.actor !== expectedActorID) {
throw new Error('Lote de importação inválido.')
}
- const expiresAtMs = doc.expiresAt ? Date.parse(doc.expiresAt) : NaN
+ const expiresAtMs = Date.parse(doc.expiresAt)
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
throw new Error('Lote de importação expirado.')
}
@@ -114,14 +108,6 @@ export const deleteSupporterImportBatch = async (
})
}
-const base64url = (buffer: Buffer): string =>
- buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
-
-const base64urlToBuffer = (value: string): Buffer => {
- const padded = value.replace(/-/g, '+').replace(/_/g, '/')
- return Buffer.from(padded, 'base64')
-}
-
const getSecret = (): string => {
const secret = process.env.PAYLOAD_SECRET
if (!secret) throw new Error('PAYLOAD_SECRET não configurado para o token de importação.')
@@ -140,7 +126,7 @@ export type SignedImportToken = {
export const issueSupporterImportToken = (actorID: number): SignedImportToken => {
const batchId = randomUUID()
const expiresAt = Date.now() + IMPORT_TOKEN_TTL_MS
- const sig = base64url(signature(batchId, actorID, expiresAt))
+ const sig = signature(batchId, actorID, expiresAt).toString('base64url')
return { token: `${batchId}.${expiresAt}.${sig}`, batchId, expiresAt }
}
@@ -156,7 +142,7 @@ export const verifySupporterImportToken = (
throw new Error('Token de importação expirado.')
}
const expected = signature(batchId, actorID, expiresAt)
- const provided = base64urlToBuffer(sig)
+ const provided = Buffer.from(sig, 'base64url')
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
throw new Error('Token de importação inválido.')
}
diff --git a/src/utilities/supporterListOverviewAggregate.ts b/src/utilities/supporterListOverviewAggregate.ts
index 6550546c..32fbe155 100644
--- a/src/utilities/supporterListOverviewAggregate.ts
+++ b/src/utilities/supporterListOverviewAggregate.ts
@@ -46,9 +46,7 @@ const resolveAccessConstraint = async (
overrideAccess: true,
})
- const ids = result.docs
- .map((doc) => (typeof doc.id === 'number' ? doc.id : Number(doc.id)))
- .filter((id) => Number.isSafeInteger(id))
+ const ids = result.docs.map((doc) => doc.id)
return { kind: 'nucleusSet', ids }
}