Deliver C8 scale/DRY for supporters and register C9 follow-ups.

Bulk import and list paths get single-round-trip locks, drizzle returning reads, pg_trgm search, shared drizzleBulk helpers, and consolidated campaign formActions; simplify debts land as C9 in the roadmap.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 00:30:16 -03:00
parent 39f7edb10f
commit 1e23b27b4f
28 changed files with 597 additions and 404 deletions

View file

@ -155,7 +155,7 @@ Todas as cinco fases foram implementadas e validadas localmente (`tsc --noEmit`,
- **Bulk insert deve rodar na txn Payload existente, não numa txn drizzle nova.** O seed TSE (`electionResultsImport.ts`) abre `dbAdapter.drizzle.transaction(...)` própria; para C4 isso quebraria a atomicidade com os locks advisory. Usado `getPostgresTransactionDatabase(payload, req)` para pegar a sessão drizzle vinculada à txn Payload.
- **`skipContactPhoneInvariant` é fail-closed por txn, não por introspecção de lock.** Não há maneira barata de perguntar ao PG "seguro o lock X?"; o proxy usado é `req.transactionID` ativo (o lock `pg_advisory_xact_lock` é xact-level e só existe dentro de uma txn). Sem txn + flag setado → throw 500.
- **`supporter.beforeChange` (coexistência com liderança) é no-op para import sem núcleo.** O hook só dispara quando `contactID` e `nucleusID` estão presentes; import cria supporters sem núcleo, então o bypass via drizzle insert é seguro. `createdBy` é setado explicitamente no row bulk.
- **Escopo do mapper de form errors:** `ActionPlanPagination` e os forms de planos também consomem `campaignListUrl`/`CampaignListPagination`, mas o mapper de form actions de planos ficou para C7 (escopo de copy/mensagens diferente).
- **Escopo do mapper de form errors:** `ActionPlanPagination` e os forms de planos também consomem `campaignListUrl`/`CampaignListPagination`. O mapper de form actions de planos (`planos/formActions.ts`, `planos/[slug]/updateFormActions.ts`) ficou de fora do escopo original do C7 (copy/mensagens diferentes) mas foi consolidado pelo C8 (`escala-dry-pos-c6.md`), junto com os demais `formActions` restantes — C7 Fase 3 manteve apenas os helpers de FormData/URL de território do `actionPlan`.
## Simplify (2026-07-19)

View file

@ -1,6 +1,6 @@
# Escala e DRY pós-C6 (apoiadores / import / listas)
Status: rascunho
Status: implementado (pendente merge em `main`)
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C8)
Responsável: —
@ -118,3 +118,17 @@ flowchart TD
- `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
## Simplify (2026-07-19)
Duas passagens `/simplify` sobre o diff do C8 (pré- e pós-rebase em `main` com C7) aplicaram limpezas pontuais sem mudar comportamento: `safeMessages` opcional em `mapCampaignFormActionError`, `drizzleResultRows` reutilizado em `campaignInviteRepository`, chunk de `IN` nas leituras drizzle do bulk import, `select: { id: true }` via `DynamicFind` em `getCoordinatorNucleusIds`, prefetch de núcleos do coordenador para o overview, `errorProps` no `CampaignInviteForm`/`NucleusUpdateForm`, e hoisting de erros de campo. Validado com `tsc --noEmit`, `pnpm lint`, `pnpm test:int`, `pnpm build` e scan Aikido.
Os débitos que os revisores marcaram como importantes e maiores que cleanup foram registrados como item **C9** — [escala-dry-pos-c8.md](escala-dry-pos-c8.md):
1. **Filtros duplicados** — `buildSupporterListWhere` (Payload) vs `buildAggregateSql` (SQL cru); risco de drift entre KPIs e lista.
2. **`contactSearchQuery` não wireado no aggregate** — overview ainda dispara `ILIKE` para `q` de 1 caractere apesar do `pg_trgm` e do guard de C7.
3. **Lookup de núcleos do coordenador repetido** — até 3× por navegação em `/campanha/apoiadores` (list access + options + overview prefetch).
4. **`apoiadores/[id]/formActions.ts` fora do mapper** — contrato `message` vs `fieldErrors` exige desenho antes de migrar.
5. **Componentes de form com `fieldError` local** — `NucleusForm`, `ActionPlanForm`, `NucleusTerritoryFields`, `VoteEstimateDialog`, etc.
**Explicitamente fora (tradeoffs aceitos no C8):** waterfall lista→overview (precisa de `totalDocs`); unificar lista+aggregate numa query; widen de `PostgresTransactionDatabase`.

View file

@ -0,0 +1,108 @@
# Escala e DRY pós-C8 (apoiadores / filtros / forms)
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C9)
Responsável: —
## Contexto
O C8 ([escala-dry-pos-c6.md](escala-dry-pos-c6.md)) entregou a segunda camada de escala/DRY do cadastro nominal: locks advisory em um round-trip, bulk import com `.returning()` e leituras drizzle na txn, overview sem `COUNT(*)` redundante, migration `pg_trgm` em `contact`, helper `drizzleBulk.ts`, variante `getCoordinatorNucleusIds`, e migração da maioria dos `formActions` para `mapCampaignFormActionError` + `campaignFormFields`. Duas passagens `/simplify` (pré- e pós-rebase em `main` com C7) aplicaram limpezas adicionais (`select: { id: true }` via `DynamicFind`, prefetch de núcleos do coordenador para o overview, chunk de `IN` no import, `drizzleResultRows` no `campaignInviteRepository`, `errorProps` no `CampaignInviteForm`, `safeMessages` opcional no mapper).
Os revisores (quality / reuse / performance) marcaram como **importantes e maiores que cleanup** os follow-ups abaixo. Sem registro, a lista de apoiadores pode divergir entre Payload e SQL agregado, o coordenador paga 2–3 lookups idênticos por navegação, e o DRY de forms fica incompleto na borda da vertical.
1. **Filtros de apoiadores duplicados.** `buildSupporterListWhere` (`src/utilities/supporterUi.ts`, Payload `Where`) e `buildAggregateSql` (`src/utilities/supporterListOverviewAggregate.ts`, SQL cru) reimplementam a mesma semântica (telefone normalizado, `q`, `voteIntention`, `city`, `nucleus`). O C8 derivou `total` da lista, mas os dois caminhos ainda podem divergir em qualquer mudança futura.
2. **`contactSearchQuery` não wireado no aggregate.** C7 entregou `isContactSearchQueryReady` / `CONTACT_SEARCH_MIN_LENGTH` em `src/lib/contactSearchQuery.ts` (mín. 2 chars) para comboboxes; o aggregate SQL do overview ainda aplica `ILIKE '%q%'` para `q` de 1 caractere — desperdiça o `pg_trgm` do C8.
3. **Lookup de núcleos do coordenador repetido na página de apoiadores.** `getCoordinatorNucleusIds` roda no prefetch do overview, mas `loadSupporterListPageData` e `loadAccessibleNucleusOptions` disparam access separado (`getAccessibleNucleusIds` → mesma query) sem `req.context` compartilhado em RSC — até 3× por navegação para `coordenador`.
4. **`apoiadores/[id]/formActions.ts` fora do mapper compartilhado.** C8 migrou ~8 `formActions`, mas o detalhe do apoiador mantém ladder inline com contrato de erro diferente (`message` em validação Zod, não `fieldErrors`) — drift e risco de regressão se migrado sem desenho.
5. **Componentes de form com `fieldError` local.** `NucleusForm`, `ActionPlanForm`, `NucleusTerritoryFields`, `VoteEstimateDialog`, `ActionPlanUpdateForm`, etc. ainda usam `errorFor` ou `fieldErrors?.[0]` em vez de `campaignFormFields.ts` — fora do escopo mínimo do C8 Fase 4 (só `NucleusUpdateForm` + `CampaignInviteForm`).
**Explicitamente fora (revisores pediram skip no simplify):** remover o waterfall lista→overview (tradeoff intencional para `totalDocs` correto); unificar lista+aggregate numa query só (só se latency reclamar); migrar `collectLockParams` do teste de locks; widen de `PostgresTransactionDatabase` (follow-up de plataforma, coberto parcialmente por `assertDrizzleColumns`).
## Objetivos
- Um único núcleo de intenção de filtro de apoiadores alimenta Payload (`Where`) e SQL agregado — sem drift entre KPIs e lista.
- Busca `q` no overview (e, se ainda faltar, na lista Payload) respeita `contactSearchQuery` (mín. 2 chars ou dígitos suficientes), alinhado ao C7.
- Página `/campanha/apoiadores` resolve escopo de núcleos do coordenador **uma vez** por render e repassa para list/overview/options.
- Forms restantes da campanha usam `mapCampaignFormActionError` / `fieldError` / `errorProps` sem quebrar contratos de UI existentes.
- Guardrails: sem novo `Consent`; sem migration salvo se unificar filtros exigir índice novo (improvável); `overrideAccess: false` com `user`; locks advisory xact-level inalterados.
## Decisões travadas
- **Um item C9, quatro fases ordenadas.** Mesmo racional do C6/C7/C8: um ID de roadmap, PRs por fase. Ordem: filtros (correção) → `contactSearchQuery` + loader da página → forms restantes (detalhe + componentes).
- **Dependência dura de C8 (merge).** O código otimizado é o do C8; não reabre decisões de v1 do C6 (token HMAC, `supporterImportBatch`, `skipContactPhoneInvariant`).
- **Dependência suave de C7** para `contactSearchQuery` — já em `main`; C9 só wireia nos paths de apoiadores que ainda ignoram o guard.
- **Cortável se a base nominal permanecer pequena.** Fases 1–2 só rendem com volume real ou coordenadores ativos; Fases 3–4 (DRY de forms) são baratas e reduzem drift — manter se houver folga.
- **`apoiadores/[id]/formActions`:** migrar só com `resolveBoundaryMessage` / branch pré-mapper que preserve `message` onde a UI espera texto global (não field error).
- **i18n e naming** (AGENTS.md): identificadores em inglês (`buildSupporterListFilterConditions`, `loadSupportersPageData`), strings visíveis em pt-BR.
## Questões em aberto
- **Filtro unificado: AST neutro vs helper de termos de busca?** **Recomendação:** extrair pelo menos `buildSupporterSearchTerms(q)` compartilhado (telefone, dígitos, ILIKE) na Fase 1; AST completo Payload↔SQL só se a Fase 1 ainda duplicar branches de `voteIntention`/`nucleus`.
- **Memo de núcleos: `React.cache(getCoordinatorNucleusIds)` vs `loadSupportersPageData`?** **Recomendação:** loader único `loadSupportersPageData` que retorna `{ list, overviewInputs, nucleusOptions, coordinatorNucleusIds }` — mais explícito que cache implícito e testável em int.
- **Migrar componentes de form fora do diff do C8?** **Recomendação:** sim na Fase 4 — são o mesmo débito DRY da vertical; escopo mecânico (`fieldError` / `errorProps`), sem mudar copy.
## Abordagem proposta
```mermaid
flowchart TD
C8["C8 apoiadores ✓"] --> F1
F1["Fase 1 — Filtro unificado<br/>supporterUi + aggregate SQL"]
F1 --> F2["Fase 2 — contactSearchQuery + loader página<br/>1× núcleos coordenador"]
F2 --> F3["Fase 3 — formActions detalhe apoiador<br/>contrato message preservado"]
F3 --> F4["Fase 4 — fieldError nos componentes restantes"]
C7["C7 contactSearchQuery ✓"] -.-> F2
```
### Fase 1 — Núcleo de filtros de apoiadores
- Extrair condições compartilhadas de `buildSupporterListWhere` e `buildAggregateSql` para `src/utilities/supporterListFilters.ts` (ou equivalente): `q`, telefone, `voteIntention`, `city`, `nucleus`, mais adaptadores `toPayloadWhere(state)` / `toAggregateSqlConditions(state, access)`.
- Testes int: mesmo `SupporterListState` produz o mesmo conjunto filtrado na lista e no overview (fixture com `q`, cidade, núcleo, intenção).
- Critério: mudança futura de filtro exige editar um só lugar.
### Fase 2 — `contactSearchQuery` + loader da página
- Em `buildAggregateSql` (e `buildSupporterListWhere` se ainda aceitar `q` curto): ignorar `q` quando `!isContactSearchQueryReady(q)` — alinhado a `ContactCombobox` / C7.
- Criar `loadSupportersPageData` em `supporterPageData.ts`: uma chamada a `getCoordinatorNucleusIds` para `coordenador`, repassar IDs para list constraints (se viável) + overview + evitar prefetch duplicado na `page.tsx`.
- Avaliar `React.cache` só como otimização secundária se o loader único não bastar.
### Fase 3 — `apoiadores/[id]/formActions.ts`
- Migrar `setSupporterVoteIntentionFormAction` / `removeSupporterDataFormAction` para `mapCampaignFormActionError` com `resolveBoundaryMessage` ou branches que mantêm `message` (não `fieldErrors`) onde `SupporterDetail` espera banner global.
- Espelhar padrão de `apoiadores/novo/formActions.ts` (`getSupporterFormError`) onde aplicável.
### Fase 4 — Componentes de form restantes
- Substituir `errorFor` / `state.fieldErrors?.x?.[0]` por `fieldError` ou `errorProps` de `campaignFormFields.ts` em: `NucleusForm`, `ActionPlanForm`, `NucleusTerritoryFields`, `NucleusTerritoryAndZonesFields`, `VoteEstimateDialog`, `ActionPlanUpdateForm`, `LeadershipPrimaryContactAction`, `NucleusIntelligenceDialog` (lista do simplify 2026-07-19).
- Opcional baixa prioridade: `planos/formActions` regex de unique → pre-check de título como em `nucleos/formActions` (`existingActionPlanState`).
**Migration:** nenhuma prevista. Sem Consent novo.
## Dependências
- **Dura:** C8 Escala e DRY pós-C6 (merge) — código em `supporterListOverviewAggregate.ts`, `supporterPageData.ts`, `supporterUi.ts`, `campaignFormActionError.ts`.
- **Suave:** C7 `contactSearchQuery` — já em `main`; Fase 2 wireia.
- Reusa: `src/lib/contactSearchQuery.ts`, `getCoordinatorNucleusIds`, `campaignFormFields.ts`, padrão `loadSupporterListPageData`, plano C8 [escala-dry-pos-c6.md](escala-dry-pos-c6.md).
## Não escopo
- Reabrir C6/C8 (import bulk, token HMAC, `pg_trgm` migration, locks batch).
- Unificar lista+overview numa única query SQL (só se medição de latency justificar).
- Widen `PostgresTransactionDatabase` — follow-up de plataforma (assertion runtime do C8 cobre drift de colunas no bulk).
- Helpers FormData/URL de território do actionPlan — C7 Fase 3 / [escala-dry-pos-c3.md](escala-dry-pos-c3.md).
- GOTV / dia D — C5.
## Referências
- `docs/roadmap.md` — Trilha C, item C9; sequência Janela 2
- [escala-dry-pos-c6.md](escala-dry-pos-c6.md) — C8 entregue; precedente do formato
- [escala-dry-pos-c3.md](escala-dry-pos-c3.md) — `contactSearchQuery` (C7)
- [escala-dry-pos-c2.md](escala-dry-pos-c2.md) — cadastro nominal C2/C6
- `src/utilities/supporterUi.ts` — `buildSupporterListWhere`
- `src/utilities/supporterListOverviewAggregate.ts` — `buildAggregateSql`, `resolveAccessConstraint`
- `src/app/(campaign)/campanha/(app)/apoiadores/page.tsx` — prefetch / waterfall overview
- `src/lib/contactSearchQuery.ts` — `isContactSearchQueryReady`
- `src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts` — ladder legado
- `src/utilities/campaignFormFields.ts` — `fieldError`, `errorProps`
- AGENTS.md — `Contact`, `overrideAccess: false`, naming inglês

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; 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 e mesclado em `main` (Fases 1–5 + simplify); C7 parcial 2026-07-19 (FormData território, contadores lista, locks, contactSearchQuery) — débitos pós-simplify registrados no plano; 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`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md))
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; 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 e mesclado em `main` (Fases 1–5 + simplify); C7 parcial 2026-07-19 (FormData território, contadores lista, locks, contactSearchQuery) — débitos pós-simplify registrados no plano; C8 Escala e DRY pós-C6 implementado (Fases 1–4 + duas passagens `/simplify`; pendente merge em `main`); C9 registrado — escala/DRY pós-C8 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md))
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.
@ -75,6 +75,9 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de
- **C2 Cadastro nominal de apoiadores** — collection `supporter` (join `Contact`↔campanha, núcleo opcional), migration `20260718_222656_add_supporter` com `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent por chaves estáveis `apoiador-cadastro` / `apoiador-intencao-voto` via `campaignConsent.ts` genérico (`getConsentByKey` / `requireConsentByKey`, falha fechada), actions (create / intenção de voto / import CSV só `geral` / `removeSupporterData`), UI `/campanha/apoiadores` (lista+KPIs, ficha, wizard de import, kit mínimo `wa.me`). Telefone obrigatório no v1; `lideranca` sem acesso à área. Engenharia pronta e mesclada — produção com dados reais espera deploy (build Vercel aplica a migration) + Consent keys + aprovação jurídica (Onda 0). [Plano](plans/cadastro-nominal-apoiadores.md).
- **C3 Eventos / agenda de mobilização** — collection `actionPlan` + vertical `/campanha/planos` (lista com tabs Próximos/Todos/Realizados/Rascunhos, detalhe com tabs Visão geral/Tarefas/Atualizações, forms novo/editar), blocos "Próximos eventos" no overview de núcleos e no dashboard; `startAt` opcional só em rascunho (obrigatório ao sair de `rascunho`); access por `coordinators`/`leadership` (escopo `lideranca` só toggle `tasks.done` + append `updates`); transações via `withPayloadTransaction`; migration `20260718_222832_add_action_plan`. Sem `Consent` (dado interno de staff). [Plano](plans/eventos-agenda-mobilizacao.md).
- **C7 Escala e DRY pós-C3 — parcial (2026-07-19)** — primeira fatia: `campaignTerritoryFormData`, contadores `taskTotal`/`taskDoneCount` + migration `20260719_014906_action_plan_list_perf`, locks advisory + `select` mínimo em toggle/append, `contactSearchQuery` (mín. 2 chars), rename `CampaignTerritoryFields`. Débitos do `/simplify` 2026-07-19 (composição territorial, shell de contato, leituras por aba, short-circuit de hooks, `taskProgress` no detalhe) registrados no plano — Fases 1–2 e restante de 4–5 pendentes. [Plano](plans/escala-dry-pos-c3.md).
- **C6 Escala e DRY pós-C2 — implementado e mesclado em `main` (2026-07-19)** — import bulk drizzle, token HMAC + `supporterImportBatch`, KPI aggregate, shells compartilhados (`campaignListUrl`, `CampaignListPagination`, `campaignFormFields`, `mapCampaignFormActionError`). Duas passagens `/simplify`; débitos maiores → C8. [Plano](plans/escala-dry-pos-c2.md).
- **C8 Escala e DRY pós-C6 — implementado (2026-07-19; pendente merge)** — locks advisory em 1 RT, bulk com `.returning()` + leituras drizzle na txn, overview sem `COUNT(*)` redundante, migration `pg_trgm` em `contact`, `drizzleBulk.ts`, `getCoordinatorNucleusIds`, migração da maioria dos `formActions` para `mapCampaignFormActionError`. Duas passagens `/simplify` (pré- e pós-rebase com C7); débitos maiores → C9. [Plano](plans/escala-dry-pos-c6.md).
- **C9 Escala e DRY pós-C8 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: filtro unificado Payload↔SQL, wire de `contactSearchQuery` no aggregate, loader único da página de apoiadores (1× núcleos do coordenador), `apoiadores/[id]/formActions` + componentes de form restantes. [Plano](plans/escala-dry-pos-c8.md).
### Ciclo 3 — Trilha E: mapa de projeção por município (registrado 2026-07-19)
@ -104,6 +107,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| 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) |
| C9 Escala e DRY pós-C8 | — (sem design novo; reusa os de C2/C6/C8) | [escala-dry-pos-c8.md](plans/escala-dry-pos-c8.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) |
@ -147,6 +151,7 @@ flowchart TD
C3["C3 Eventos / agenda de mobilização ✓"]
C7["C7 Escala e DRY pós-C3<br/>(parcial: contadores/locks/FormData)"]
C8["C8 Escala e DRY pós-C6<br/>(locks bulk / leituras / forms DRY)"]
C9["C9 Escala e DRY pós-C8<br/>(filtros unificados / loader / forms)"]
C4["C4 Demandas"]
C5["C5 Operação dia D / GOTV<br/>(proposto, a validar)"]
end
@ -191,6 +196,7 @@ flowchart TD
C2 --> C5
C6 -.escala da base.-> C5
C6 --> C8
C8 --> C9
D1 --> D2
JUR -.chave de push.-> D2
A4 --> E2
@ -203,7 +209,7 @@ flowchart TD
A7 -.mesmo loader do baseline.-> E2
```
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6 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: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), 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), 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). E4 destravará após E1+E3 (campos precisam existir antes do import).
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C8 implementado (pendente merge). C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), 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), C7 (após C3; suave: C6 F2 ✓), C8 (merge de C6 ✓; cortável se a base nominal permanecer pequena), C9 (merge de C8; cortável se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import).
### Sequência de execução por janela do calendário
@ -227,31 +233,32 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fil
| 9 | A4 Baseline no produto + insight Gap vs 2022 **(implementado e mesclado em `main` 2026-07-18 — Fases 2–4 + simplify)** | [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 _(parcial 2026-07-19: FormData território, contadores lista, locks, `contactSearchQuery`; pendente: composição territorial, shell de contato, leituras por aba, short-circuit de hooks)_ | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8, E1+E3, E2 |
| 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, E1+E3, E2 |
| 13 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
| 14 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8 |
| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) **(implementado 2026-07-19 — Fases 1–4 + simplify; pendente merge)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7, E1+E3, E2, C9 |
| 13 | C9 Escala e DRY pós-C8 (filtro unificado Payload↔SQL, `contactSearchQuery` no aggregate, loader único `/apoiadores`, forms restantes) _(cortável se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas)_ | [detalhes](plans/escala-dry-pos-c8.md) | C8 (merge) | A4, C3, C7, E1+E3, E2 |
| 14 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
| 15 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8, C9 |
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------- |
| 15 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 + E3 (suave: E2 — tendência visível na revisão) | A5, A7, B3, B5, C4, D2 |
| 16 | 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 | A7, B3, B5, C4, E4, D1 |
| 17 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, B3, B5, C4, E4, D1 |
| 18 | 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; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, B5, C4, E4, D1 |
| 19 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, B3, C4, E4, D1 |
| 20 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, B3, B5, E4, D1 |
| 21 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
| 22 | 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, A7, B3, B5, C4, E4 |
| 16 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 + E3 (suave: E2 — tendência visível na revisão) | A5, A7, B3, B5, C4, D2 |
| 17 | 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 | A7, B3, B5, C4, E4, D1 |
| 18 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, B3, B5, C4, E4, D1 |
| 19 | 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; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, B5, C4, E4, D1 |
| 20 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, B3, C4, E4, D1 |
| 21 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, B3, B5, E4, D1 |
| 22 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
| 23 | 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, A7, B3, B5, C4, E4 |
**Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.**
| Ordem | Item | Plano | Depende de |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| 23 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
| 24 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
| 25 | 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) |
| 26 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. E5 (Salvador por bairro) fica registrado para depois da eleição, condicionado a decisão de produto. | — | — |
| 24 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
| 25 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
| 26 | 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) |
| 27 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. E5 (Salvador por bairro) fica registrado para depois da eleição, condicionado a decisão de produto. | — | — |
**Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):**
@ -260,7 +267,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fil
- **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): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), 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 restantes (leituras por aba / short-circuit de hooks / feed O(n) — só se a agenda permanecer pequena; contadores + locks já entregues), 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, C8 fases 3–4 (DRY de helpers/forms) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), 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 restantes (leituras por aba / short-circuit de hooks / feed O(n) — só se a agenda permanecer pequena; contadores + locks já entregues), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2, C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
### Itens consolidados/removidos nesta revisão (2026-07-17)

View file

@ -18,7 +18,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { isCampaignGeneral } from '@/utilities/campaignAccess'
import { isCampaignGeneral, getCoordinatorNucleusIds } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
loadAccessibleNucleusOptions,
@ -31,7 +31,6 @@ import {
buildSupporterListHref,
canAccessSupporterArea,
getSupporterScopeLabel,
resolveSupporterListUrl,
} from '@/utilities/supporterUi'
type SupportersPageProps = {
@ -45,14 +44,24 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
if (!canAccessSupporterArea(user.role)) redirect('/campanha')
const rawSearchParams = await searchParams
const provisional = resolveSupporterListUrl(rawSearchParams)
const [{ result, state, redirectHref }, overview, nucleusOptions] = await Promise.all([
loadSupporterListPageData(payload, user, rawSearchParams),
loadSupporterListOverviewData(payload, user, provisional.state),
loadAccessibleNucleusOptions(payload, user),
])
const [{ result, state, redirectHref }, nucleusOptions, coordinatorNucleusIds] =
await Promise.all([
loadSupporterListPageData(payload, user, rawSearchParams),
loadAccessibleNucleusOptions(payload, user),
user.role === 'coordenador'
? getCoordinatorNucleusIds(payload, user.id)
: Promise.resolve(undefined),
])
if (redirectHref) redirect(redirectHref)
const overview = await loadSupporterListOverviewData(
payload,
user,
state,
result.totalDocs,
coordinatorNucleusIds,
)
return (
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">

View file

@ -3,15 +3,11 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { revalidatePath } from 'next/cache'
import { ZodError } from 'zod'
import { assignNucleusCoordinators } from '@/app/(campaign)/campanha/actions/coordinatorAssignment'
import {
FormDataBoundaryError,
repeatedRelationshipFormValues,
validationFieldErrors,
} from '@/lib/formData'
import { repeatedRelationshipFormValues } from '@/lib/formData'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions'
export type CoordinatorAssignmentFormState = {
@ -25,23 +21,19 @@ export type CoordinatorAssignmentOptionsResult = {
options: Array<{ id: number; name: string; isCurrent: boolean }>
}
const knownMessages = new Set([
const knownMessages = [
'Somente a coordenação geral pode alterar coordenadores.',
'Núcleo não encontrado ou sem acesso.',
'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.',
'Uma ou mais pessoas selecionadas não são mais elegíveis para coordenação.',
])
] as const
const errorState = (error: unknown): CoordinatorAssignmentFormState => {
if (error instanceof FormDataBoundaryError) {
return { fieldErrors: { [error.field]: [error.message] } }
}
if (error instanceof ZodError) return { fieldErrors: validationFieldErrors(error) }
if (error instanceof Error && knownMessages.has(error.message)) {
return { message: error.message }
}
return { message: 'Não foi possível salvar a coordenação. Atualize a página e tente novamente.' }
}
const errorState = (error: unknown): CoordinatorAssignmentFormState =>
mapCampaignFormActionError({
error,
safeMessages: knownMessages,
genericMessage: 'Não foi possível salvar a coordenação. Atualize a página e tente novamente.',
})
export const assignNucleusCoordinatorsFormAction = async (
slug: string,

View file

@ -1,10 +1,9 @@
'use server'
import { revalidatePath } from 'next/cache'
import { ZodError } from 'zod'
import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus'
import { FormDataBoundaryError, validationFieldErrors } from '@/lib/formData'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import { parseNucleusIntelligenceFormData } from '@/utilities/nucleusIntelligenceUi'
export type NucleusIntelligenceFormState = {
@ -23,12 +22,10 @@ export const updateNucleusIntelligenceFormAction = async (
revalidatePath('/campanha/nucleos/[slug]', 'page')
return { status: 'success', message: 'Inteligência do núcleo atualizada.' }
} catch (error) {
if (error instanceof FormDataBoundaryError) {
return { fieldErrors: { [error.field]: [error.message] } }
}
if (error instanceof ZodError) return { fieldErrors: validationFieldErrors(error) }
return {
message: 'Não foi possível atualizar a inteligência. Verifique os dados e tente novamente.',
}
return mapCampaignFormActionError({
error,
genericMessage:
'Não foi possível atualizar a inteligência. Verifique os dados e tente novamente.',
})
}
}

View file

@ -3,12 +3,11 @@
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { revalidatePath } from 'next/cache'
import { ZodError } from 'zod'
import { createNucleusUpdateRecord } from '@/app/(campaign)/campanha/actions/nucleusUpdate'
import type { CampaignUser } from '@/payload-types'
import { FormDataBoundaryError, validationFieldErrors } from '@/lib/formData'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import { parseNucleusUpdateFormData } from '@/utilities/nucleusUpdateUi'
import { requireRelationshipId } from '@/utilities/relationship'
@ -32,15 +31,10 @@ export const createNucleusUpdateFormRecordAction = async (
nucleusId: requireRelationshipId(created.nucleus),
}
} catch (error) {
if (error instanceof FormDataBoundaryError) {
return { fieldErrors: { [error.field]: [error.message] } }
}
if (error instanceof ZodError) {
return { fieldErrors: validationFieldErrors(error) }
}
return {
message: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
}
return mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -1,19 +1,17 @@
'use server'
import { revalidatePath } from 'next/cache'
import { ZodError } from 'zod'
import {
confirmVoteEstimate,
suggestVoteEstimate,
} from '@/app/(campaign)/campanha/actions/voteEstimate'
import {
FormDataBoundaryError,
optionalFormText,
requiredIntegerFormValue,
requiredRelationshipFormValue,
validationFieldErrors,
} from '@/lib/formData'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
export type VoteEstimateFormState = {
status?: 'success'
@ -21,24 +19,19 @@ export type VoteEstimateFormState = {
fieldErrors?: Record<string, string[]>
}
const errorState = (error: unknown): VoteEstimateFormState => {
if (error instanceof FormDataBoundaryError) {
return { fieldErrors: { [error.field]: [error.message] } }
}
if (error instanceof ZodError) return { fieldErrors: validationFieldErrors(error) }
if (
error instanceof Error &&
[
'A liderança precisa de vínculo engajado com este núcleo.',
'Somente a coordenação pode confirmar estimativas.',
'A sugestão foi alterada. Atualize a página antes de confirmar.',
'Informe uma justificativa para ajustar a estimativa.',
].includes(error.message)
) {
return { message: error.message }
}
return { message: 'Não foi possível salvar a estimativa. Tente novamente.' }
}
const safeMessages = [
'A liderança precisa de vínculo engajado com este núcleo.',
'Somente a coordenação pode confirmar estimativas.',
'A sugestão foi alterada. Atualize a página antes de confirmar.',
'Informe uma justificativa para ajustar a estimativa.',
] as const
const errorState = (error: unknown): VoteEstimateFormState =>
mapCampaignFormActionError({
error,
safeMessages,
genericMessage: 'Não foi possível salvar a estimativa. Tente novamente.',
})
export const suggestVoteEstimateFormAction = async (
_state: VoteEstimateFormState,

View file

@ -3,7 +3,6 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { redirect } from 'next/navigation'
import { ZodError } from 'zod'
import {
archiveNucleus,
@ -11,29 +10,20 @@ import {
updateNucleus,
} from '@/app/(campaign)/campanha/actions/nucleus'
import type { NucleusFormState } from '@/components/campaign/NucleusForm'
import {
FormDataBoundaryError,
optionalFormText,
requiredRelationshipFormValue,
validationFieldErrors,
} from '@/lib/formData'
import { optionalFormText, requiredRelationshipFormValue } from '@/lib/formData'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import {
parseNucleusCreateFormData,
parseNucleusUpdateFormData,
} from '@/utilities/nucleusFormData'
import { slugify } from '@/utilities/slug'
const getActionError = (error: unknown): NucleusFormState => {
if (error instanceof FormDataBoundaryError) {
return { fieldErrors: { [error.field]: [error.message] } }
}
if (error instanceof ZodError) {
return { fieldErrors: validationFieldErrors(error) }
}
return { message: 'Não foi possível concluir a ação. Atualize a página e tente novamente.' }
}
const getActionError = (error: unknown): NucleusFormState =>
mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível concluir a ação. Atualize a página e tente novamente.',
})
const existingNucleusState = async (formData: FormData): Promise<NucleusFormState | null> => {
const name = optionalFormText(formData, 'name')

View file

@ -1,15 +1,10 @@
'use server'
import { revalidatePath } from 'next/cache'
import { ZodError } from 'zod'
import { appendActionPlanUpdate } from '@/app/(campaign)/campanha/actions/actionPlan'
import {
FormDataBoundaryError,
requiredFormText,
requiredRelationshipFormValue,
validationFieldErrors,
} from '@/lib/formData'
import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
export type ActionPlanUpdateFormState = {
status?: 'success'
@ -17,6 +12,15 @@ export type ActionPlanUpdateFormState = {
fieldErrors?: Record<string, string[]>
}
// The specific validation/transaction messages `appendActionPlanUpdate` throws —
// safe to surface verbatim. Anything else (e.g. an unexpected Payload API error)
// now falls back to the generic message instead of leaking its raw text.
const safeMessages = [
'Informe o texto da atualização.',
'Atualização muito longa. Reduza o texto e tente novamente.',
'Não foi possível registrar a atualização do plano.',
] as const
export const createActionPlanUpdateFormAction = async (
_state: ActionPlanUpdateFormState,
formData: FormData,
@ -28,17 +32,10 @@ export const createActionPlanUpdateFormAction = async (
revalidatePath('/campanha/planos/[slug]', 'page')
return { status: 'success', message: 'Atualização registrada com sucesso.' }
} catch (error) {
if (error instanceof FormDataBoundaryError) {
return { fieldErrors: { [error.field]: [error.message] } }
}
if (error instanceof ZodError) {
return { fieldErrors: validationFieldErrors(error) }
}
return {
message:
error instanceof Error
? error.message
: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
}
return mapCampaignFormActionError({
error,
safeMessages,
genericMessage: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -3,16 +3,12 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { redirect } from 'next/navigation'
import { ZodError } from 'zod'
import { createActionPlan, updateActionPlan } from '@/app/(campaign)/campanha/actions/actionPlan'
import type { ActionPlanFormState } from '@/components/campaign/ActionPlanForm'
import {
FormDataBoundaryError,
optionalFormText,
validationFieldErrors,
} from '@/lib/formData'
import { optionalFormText } from '@/lib/formData'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import {
parseActionPlanCreateFormData,
parseActionPlanUpdateFormData,
@ -20,17 +16,16 @@ import {
import { slugify } from '@/utilities/slug'
const getActionError = (error: unknown): ActionPlanFormState => {
if (error instanceof FormDataBoundaryError) {
return { fieldErrors: { [error.field]: [error.message] } }
}
if (error instanceof ZodError) {
return { fieldErrors: validationFieldErrors(error) }
}
// Checked before the shared mapper: a DB-level unique-violation message isn't
// a `FormDataBoundaryError`/`ZodError`/known-safe string, so it needs its own
// branch to surface as a field error instead of falling to the generic message.
if (error instanceof Error && /já existe|unique|duplicate key/i.test(error.message)) {
return { fieldErrors: { title: ['Já existe um plano com este título.'] } }
}
return { message: 'Não foi possível concluir a ação. Atualize a página e tente novamente.' }
return mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível concluir a ação. Atualize a página e tente novamente.',
})
}
const existingActionPlanState = async (formData: FormData): Promise<ActionPlanFormState | null> => {

View file

@ -1,7 +1,6 @@
'use server'
import { redirect } from 'next/navigation'
import { ZodError } from 'zod'
import {
redeemCampaignInviteAutofill,
@ -10,12 +9,11 @@ import {
import { campaignInviteAutofillSchema, campaignInviteLoginSchema } from '@/lib/schemas/invite'
import {
checkboxFormValue,
FormDataBoundaryError,
nullableFormText,
requiredFormSecret,
requiredFormText,
validationFieldErrors,
} from '@/lib/formData'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
export type CampaignInviteFormState = {
status?: 'success'
@ -37,17 +35,12 @@ const profileFromForm = (formData: FormData) => ({
sectorNotes: nullableFormText(formData, 'sectorNotes'),
})
const inviteFormError = (error: unknown): CampaignInviteFormState => {
if (error instanceof FormDataBoundaryError) {
return { fieldErrors: { [error.field]: [error.message] } }
}
if (error instanceof ZodError) {
return { fieldErrors: validationFieldErrors(error) }
}
return {
message: 'Este convite não está disponível. Peça um novo convite à pessoa que falou com você.',
}
}
const inviteFormError = (error: unknown): CampaignInviteFormState =>
mapCampaignFormActionError({
error,
genericMessage:
'Este convite não está disponível. Peça um novo convite à pessoa que falou com você.',
})
export const redeemCampaignInviteAutofillFormAction = async (
token: string,

View file

@ -23,6 +23,7 @@ import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { fieldError, errorProps } from '@/utilities/campaignFormFields'
import { leadershipGenderLabels, leadershipSectorLabels } from '@/utilities/leadershipUi'
type CampaignInviteProfile = {
@ -41,9 +42,6 @@ const sectorOptions = Object.entries(leadershipSectorLabels) as Array<
[NonNullable<CampaignInviteProfile['sector']>, string]
>
const firstError = (state: CampaignInviteFormState, field: string) =>
state.fieldErrors?.[field]?.[0]
const CampaignInviteFeedback = ({ state }: { state: CampaignInviteFormState }) => {
if (!state.message || state.status === 'success') return null
return (
@ -109,9 +107,17 @@ const ProfileFields = ({
state: CampaignInviteFormState
}) => {
const { profile } = preview
const inviteField = (field: string) => errorProps(state.fieldErrors, field, 'campaign-invite')
const name = inviteField('name')
const phone = inviteField('phone')
const email = inviteField('email')
const gender = inviteField('gender')
const sector = inviteField('sector')
const sectorNotes = inviteField('sectorNotes')
return (
<FieldGroup>
<Field data-invalid={Boolean(firstError(state, 'name'))}>
<Field data-invalid={name.invalid}>
<FieldLabel htmlFor="campaign-invite-name">Nome *</FieldLabel>
<Input
id="campaign-invite-name"
@ -120,15 +126,13 @@ const ProfileFields = ({
autoComplete="name"
required
maxLength={120}
aria-invalid={Boolean(firstError(state, 'name'))}
aria-describedby={firstError(state, 'name') ? 'campaign-invite-name-error' : undefined}
aria-invalid={name.invalid}
aria-describedby={name.describedBy}
/>
{firstError(state, 'name') ? (
<FieldError id="campaign-invite-name-error">{firstError(state, 'name')}</FieldError>
) : null}
{name.error ? <FieldError id={name.describedBy}>{name.error}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(firstError(state, 'phone'))}>
<Field data-invalid={phone.invalid}>
<FieldLabel htmlFor="campaign-invite-phone">Celular (WhatsApp) *</FieldLabel>
<Input
id="campaign-invite-phone"
@ -138,15 +142,13 @@ const ProfileFields = ({
defaultValue={profile.phone}
autoComplete="tel"
required
aria-invalid={Boolean(firstError(state, 'phone'))}
aria-describedby={firstError(state, 'phone') ? 'campaign-invite-phone-error' : undefined}
aria-invalid={phone.invalid}
aria-describedby={phone.describedBy}
/>
{firstError(state, 'phone') ? (
<FieldError id="campaign-invite-phone-error">{firstError(state, 'phone')}</FieldError>
) : null}
{phone.error ? <FieldError id={phone.describedBy}>{phone.error}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(firstError(state, 'email'))}>
<Field data-invalid={email.invalid}>
<FieldLabel htmlFor="campaign-invite-email">E-mail (opcional)</FieldLabel>
<Input
id="campaign-invite-email"
@ -154,26 +156,22 @@ const ProfileFields = ({
type="email"
defaultValue={profile.email ?? ''}
autoComplete="email"
aria-invalid={Boolean(firstError(state, 'email'))}
aria-describedby={firstError(state, 'email') ? 'campaign-invite-email-error' : undefined}
aria-invalid={email.invalid}
aria-describedby={email.describedBy}
/>
{firstError(state, 'email') ? (
<FieldError id="campaign-invite-email-error">{firstError(state, 'email')}</FieldError>
) : null}
{email.error ? <FieldError id={email.describedBy}>{email.error}</FieldError> : null}
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={Boolean(firstError(state, 'gender'))}>
<Field data-invalid={gender.invalid}>
<FieldLabel htmlFor="campaign-invite-gender">Gênero</FieldLabel>
<NativeSelect
id="campaign-invite-gender"
name="gender"
className="w-full"
defaultValue={profile.gender ?? ''}
aria-invalid={Boolean(firstError(state, 'gender'))}
aria-describedby={
firstError(state, 'gender') ? 'campaign-invite-gender-error' : undefined
}
aria-invalid={gender.invalid}
aria-describedby={gender.describedBy}
>
<NativeSelectOption value="">Não informar</NativeSelectOption>
{genderOptions.map(([value, label]) => (
@ -182,22 +180,18 @@ const ProfileFields = ({
</NativeSelectOption>
))}
</NativeSelect>
{firstError(state, 'gender') ? (
<FieldError id="campaign-invite-gender-error">{firstError(state, 'gender')}</FieldError>
) : null}
{gender.error ? <FieldError id={gender.describedBy}>{gender.error}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(firstError(state, 'sector'))}>
<Field data-invalid={sector.invalid}>
<FieldLabel htmlFor="campaign-invite-sector">Setor</FieldLabel>
<NativeSelect
id="campaign-invite-sector"
name="sector"
className="w-full"
defaultValue={profile.sector ?? ''}
aria-invalid={Boolean(firstError(state, 'sector'))}
aria-describedby={
firstError(state, 'sector') ? 'campaign-invite-sector-error' : undefined
}
aria-invalid={sector.invalid}
aria-describedby={sector.describedBy}
>
<NativeSelectOption value="">Não informar</NativeSelectOption>
{sectorOptions.map(([value, label]) => (
@ -206,13 +200,11 @@ const ProfileFields = ({
</NativeSelectOption>
))}
</NativeSelect>
{firstError(state, 'sector') ? (
<FieldError id="campaign-invite-sector-error">{firstError(state, 'sector')}</FieldError>
) : null}
{sector.error ? <FieldError id={sector.describedBy}>{sector.error}</FieldError> : null}
</Field>
</div>
<Field data-invalid={Boolean(firstError(state, 'sectorNotes'))}>
<Field data-invalid={sectorNotes.invalid}>
<FieldLabel htmlFor="campaign-invite-sector-notes">
Como você atua nesse setor? (opcional)
</FieldLabel>
@ -221,15 +213,11 @@ const ProfileFields = ({
name="sectorNotes"
defaultValue={profile.sectorNotes ?? ''}
maxLength={1000}
aria-invalid={Boolean(firstError(state, 'sectorNotes'))}
aria-describedby={
firstError(state, 'sectorNotes') ? 'campaign-invite-sector-notes-error' : undefined
}
aria-invalid={sectorNotes.invalid}
aria-describedby={sectorNotes.describedBy}
/>
{firstError(state, 'sectorNotes') ? (
<FieldError id="campaign-invite-sector-notes-error">
{firstError(state, 'sectorNotes')}
</FieldError>
{sectorNotes.error ? (
<FieldError id={sectorNotes.describedBy}>{sectorNotes.error}</FieldError>
) : null}
</Field>
</FieldGroup>
@ -250,9 +238,9 @@ export const CampaignInviteForm = ({
requiresConsent: boolean
}) => {
const [state, formAction, pending] = useActionState(action, {})
const consentError = firstError(state, 'consentAccepted')
const passwordError = firstError(state, 'password')
const confirmationError = firstError(state, 'passwordConfirmation')
const consentError = fieldError(state.fieldErrors, 'consentAccepted')
const passwordError = fieldError(state.fieldErrors, 'password')
const confirmationError = fieldError(state.fieldErrors, 'passwordConfirmation')
if (state.status === 'success') {
return (

View file

@ -26,6 +26,7 @@ import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { fieldError } from '@/utilities/campaignFormFields'
export type NucleusUpdateFormAction = (
state: NucleusUpdateFormState,
@ -38,11 +39,6 @@ const kinds = [
{ value: 'nota', label: 'Nota' },
] as const
const fieldError = (
fieldErrors: Record<string, string[]> | undefined,
field: string,
): string | undefined => fieldErrors?.[field]?.[0]
export const NucleusUpdateForm = ({
nucleusId,
action,
@ -59,6 +55,7 @@ export const NucleusUpdateForm = ({
const [kind, setKind] = useState<(typeof kinds)[number]['value']>('semanal')
const [state, formAction, pending] = useActionState(action, {})
const isWeekly = kind === 'semanal'
const bodyError = fieldError(state.fieldErrors, 'body')
useEffect(() => {
if (state.status !== 'success') return
@ -170,7 +167,7 @@ export const NucleusUpdateForm = ({
</div>
</>
) : (
<Field data-invalid={Boolean(fieldError(state.fieldErrors, 'body'))}>
<Field data-invalid={Boolean(bodyError)}>
<FieldLabel htmlFor="update-body">Texto da atualização *</FieldLabel>
<Textarea
id="update-body"
@ -178,16 +175,10 @@ export const NucleusUpdateForm = ({
required
maxLength={5000}
className="min-h-32"
aria-invalid={Boolean(fieldError(state.fieldErrors, 'body'))}
aria-describedby={
fieldError(state.fieldErrors, 'body') ? 'update-body-error' : undefined
}
aria-invalid={Boolean(bodyError)}
aria-describedby={bodyError ? 'update-body-error' : undefined}
/>
{fieldError(state.fieldErrors, 'body') ? (
<FieldError id="update-body-error">
{fieldError(state.fieldErrors, 'body')}
</FieldError>
) : null}
{bodyError ? <FieldError id="update-body-error">{bodyError}</FieldError> : null}
</Field>
)}
</FieldGroup>

View file

@ -0,0 +1,26 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
/**
* Makes the supporter list's `ILIKE '%q%'` search on `contact.name`/`contact.city`
* sargable via trigram GIN indexes, instead of a sequential scan per page (C8
* Fase 2 — escala-dry-pos-c6). Hand-written (not a Payload field/schema change):
* `pg_trgm` and the indexes are pure Postgres performance additions with no
* corresponding collection config. Idempotent — safe to re-run.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`CREATE EXTENSION IF NOT EXISTS pg_trgm;`)
await db.execute(
sql`CREATE INDEX IF NOT EXISTS "contact_name_trgm_idx" ON "contact" USING gin ("name" gin_trgm_ops);`,
)
await db.execute(
sql`CREATE INDEX IF NOT EXISTS "contact_city_trgm_idx" ON "contact" USING gin ("city" gin_trgm_ops);`,
)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`DROP INDEX IF EXISTS "contact_city_trgm_idx";`)
await db.execute(sql`DROP INDEX IF EXISTS "contact_name_trgm_idx";`)
// `pg_trgm` is left installed: dropping a cluster-wide extension in a
// single feature's `down` is unnecessarily invasive and other migrations
// may also depend on it going forward.
}

View file

@ -10,6 +10,7 @@ import * as migration_20260718_222656_add_supporter from './20260718_222656_add_
import * as migration_20260718_222832_add_action_plan from './20260718_222832_add_action_plan';
import * as migration_20260719_011015_add_supporter_import_batch from './20260719_011015_add_supporter_import_batch';
import * as migration_20260719_014906_action_plan_list_perf from './20260719_014906_action_plan_list_perf';
import * as migration_20260719_020000_add_contact_trgm_index from './20260719_020000_add_contact_trgm_index';
export const migrations = [
{
@ -70,6 +71,11 @@ export const migrations = [
{
up: migration_20260719_014906_action_plan_list_perf.up,
down: migration_20260719_014906_action_plan_list_perf.down,
name: '20260719_014906_action_plan_list_perf'
name: '20260719_014906_action_plan_list_perf',
},
{
up: migration_20260719_020000_add_contact_trgm_index.up,
down: migration_20260719_020000_add_contact_trgm_index.down,
name: '20260719_020000_add_contact_trgm_index',
},
];

View file

@ -1,5 +1,5 @@
import type { CampaignUser, User } from '@/payload-types'
import type { Access, FieldAccess, PayloadRequest, Where } from 'payload'
import type { Access, FieldAccess, Payload, PayloadRequest, Where } from 'payload'
import { relationshipId } from '@/utilities/relationship'
@ -16,6 +16,17 @@ const CAMPAIGN_USER_PHONE_ACCESS_CONTEXT_KEY = 'campaignUserPhoneAccess'
type LeadershipID = number
type AccessibleLeadershipIDs = LeadershipID[] | null
type DynamicFind = (args: {
collection: string
depth: number
limit: number
overrideAccess: true
pagination: false
req?: PayloadRequest
select: Record<string, true>
where: Record<string, unknown>
}) => Promise<{ docs: Array<Record<string, unknown>> }>
export const isPayloadAdmin = (user: CampaignActor): user is User => user?.collection === 'users'
const isCampaignUser = (user: CampaignActor): user is CampaignUser =>
@ -71,6 +82,34 @@ export const canCreateElectoralNucleus: Access = async ({ req }) => {
return isCampaignGeneral(await getFreshCampaignUser(req))
}
/**
* Nucleus IDs where `coordinatorID` is an assigned coordinator, without
* requiring a `PayloadRequest`. This is the canonical implementation of the
* "coordinators contains user.id" lookup: `getAccessibleNucleusIds` uses it
* for its (request-scoped, context-cached) coordinator branch, and read-only
* call sites without a `PayloadRequest` (e.g. the supporter overview
* aggregate) call it directly instead of re-implementing the query.
*/
export const getCoordinatorNucleusIds = async (
payload: Pick<Payload, 'find'>,
coordinatorID: number,
req?: PayloadRequest,
): Promise<number[]> => {
const find = payload.find.bind(payload) as unknown as DynamicFind
const result = await find({
collection: 'electoralNucleus',
where: { coordinators: { contains: coordinatorID } },
depth: 0,
limit: 0,
pagination: false,
select: { id: true },
overrideAccess: true,
...(req ? { req } : {}),
})
return result.docs.map((doc) => relationshipId(doc.id)).filter((id): id is number => id !== null)
}
export const canReadElectoralNucleus: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
@ -394,17 +433,6 @@ export const canManageActionPlanCoordinators: FieldAccess = async ({ req }) => {
return isCampaignGeneral(await getFreshCampaignUser(req))
}
type DynamicFind = (args: {
collection: string
depth: number
limit: number
overrideAccess: true
pagination: false
req: PayloadRequest
select: Record<string, true>
where: Record<string, unknown>
}) => Promise<{ docs: Array<Record<string, unknown>> }>
/**
* Returns null for unrestricted general coordination, otherwise the nucleus
* IDs currently assigned to the authenticated campaign user.
@ -432,22 +460,7 @@ export const getAccessibleNucleusIds = async (
let ids: NucleusID[] = []
if (currentUser.role === 'coordenador' && collections.electoralNucleus) {
const result = await find({
collection: 'electoralNucleus',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { id: true },
where: {
coordinators: {
contains: currentUser.id,
},
},
})
ids = result.docs.map((doc) => relationshipId(doc.id)).filter((id): id is number => id !== null)
ids = await getCoordinatorNucleusIds(req.payload, currentUser.id, req)
}
if (currentUser.role === 'lideranca' && collections.leadership) {

View file

@ -11,7 +11,7 @@ export type CampaignFormErrorState<Values> = {
export type MapCampaignFormActionErrorOptions<Values> = {
error: unknown
safeMessages: readonly string[]
safeMessages?: readonly string[]
genericMessage: string
values?: Values
revision?: number
@ -21,7 +21,14 @@ export type MapCampaignFormActionErrorOptions<Values> = {
export const mapCampaignFormActionError = <Values>(
options: MapCampaignFormActionErrorOptions<Values>,
): CampaignFormErrorState<Values> => {
const { error, safeMessages, genericMessage, values, revision, resolveBoundaryMessage } = options
const {
error,
safeMessages = [],
genericMessage,
values,
revision,
resolveBoundaryMessage,
} = options
if (error instanceof FormDataBoundaryError) {
const message = resolveBoundaryMessage ? resolveBoundaryMessage(error) : error.message

View file

@ -10,22 +10,10 @@ import {
acquireTextAdvisoryLocks,
getPostgresTransactionDatabase,
} from '@/utilities/postgresTransactionLocks'
import { drizzleResultRows } from '@/utilities/drizzleBulk'
export const INVALID_CAMPAIGN_INVITE_MESSAGE = 'Convite inválido ou expirado.'
const resultRows = (result: unknown): Array<Record<string, unknown>> => {
if (Array.isArray(result)) return result as Array<Record<string, unknown>>
if (
typeof result === 'object' &&
result !== null &&
'rows' in result &&
Array.isArray(result.rows)
) {
return result.rows as Array<Record<string, unknown>>
}
return []
}
export const requireCampaignInvitePostgres = (payload: Pick<Payload, 'db'>): void => {
if (payload.db.name !== 'postgres') {
throw new Error('O domínio de convites exige o adaptador PostgreSQL.')
@ -93,7 +81,7 @@ export const consumeCampaignInvite = async (
AND "expires_at" > now()
RETURNING "leadership_id"
`)
const leadershipID = resultRows(result)[0]?.leadership_id
const leadershipID = drizzleResultRows(result)[0]?.leadership_id
if (typeof leadershipID !== 'number') throw new Error(INVALID_CAMPAIGN_INVITE_MESSAGE)
return leadershipID
}
@ -131,7 +119,7 @@ export const findSameContactAccountIDs = async (
AND "user_id" IS NOT NULL
LIMIT 2
`)
return resultRows(result)
return drizzleResultRows(result)
.map((row) => row.user_id)
.filter((value): value is number => typeof value === 'number')
}

View file

@ -0,0 +1,63 @@
import 'server-only'
import type { Payload } from 'payload'
export type DrizzleTables = Record<string, Record<string, unknown>>
type PayloadDbTables = {
tables: DrizzleTables
}
/** ~15-16 columns × 500 ≈ 7500-8000 bind params — well under PG's 65535 param limit. */
export const INSERT_CHUNK_SIZE = 500
export const chunk = <T>(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))
return out
}
export const getDrizzleTables = (payload: Pick<Payload, 'db'>): DrizzleTables =>
(payload.db as unknown as PayloadDbTables).tables
export const requireTable = (
tables: DrizzleTables,
name: string,
): Record<string, unknown> => {
const table = tables[name]
if (!table) throw new Error(`Tabela drizzle ausente: ${name}. Rode as migrations.`)
return table
}
/**
* Fails fast when a drizzle table's JS-level column scaffold has drifted from
* what a bulk insert assumes (e.g. `contactId` vs `contact`) — catches the bug
* class before a silent bad insert instead of a runtime SQL error mid-import.
*/
export const assertDrizzleColumns = (
table: Record<string, unknown>,
tableName: string,
expectedColumns: readonly string[],
): void => {
const missing = expectedColumns.filter((column) => !(column in table))
if (missing.length > 0) {
throw new Error(
`Tabela drizzle "${tableName}" não tem as colunas esperadas: ${missing.join(', ')}. ` +
'O bulk insert usaria nomes de coluna divergentes do schema atual.',
)
}
}
/** Normalizes a drizzle `execute()` result (bare array vs `{ rows }`) into row objects. */
export const drizzleResultRows = (result: unknown): Array<Record<string, unknown>> => {
if (Array.isArray(result)) return result as Array<Record<string, unknown>>
if (
typeof result === 'object' &&
result !== null &&
'rows' in result &&
Array.isArray((result as { rows: unknown }).rows)
) {
return (result as { rows: Array<Record<string, unknown>> }).rows
}
return []
}

View file

@ -11,14 +11,12 @@ import type {
} from '@/lib/electionResults'
import { computeWinnersByScope, mergeTallyWithWinners } from '@/lib/electionResults'
import { groupByScope } from '@/lib/electionResultsParse'
import { chunk, getDrizzleTables, INSERT_CHUNK_SIZE, requireTable } from '@/utilities/drizzleBulk'
const VOTE_TABLE = 'election_candidate_vote'
const TALLY_TABLE = 'election_tally'
const CANDIDATE_TABLE = 'election_candidate'
/** ~15 columns × 500 ≈ 7500 bind params — well under PG 65535. */
const INSERT_CHUNK_SIZE = 500
export type ElectionImportScope = {
year: number
office: ElectionOffice
@ -53,25 +51,10 @@ type DrizzleTx = {
}
}
type PayloadDb = {
type PayloadDbDrizzle = {
drizzle: {
transaction: <T>(fn: (tx: DrizzleTx) => Promise<T>) => Promise<T>
}
tables: Record<string, Record<string, unknown>>
}
const chunk = <T>(rows: T[], size: number): T[][] => {
const out: T[][] = []
for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size))
return out
}
const getDb = (payload: Payload): PayloadDb => payload.db as unknown as PayloadDb
const requireTable = (db: PayloadDb, name: string) => {
const table = db.tables[name]
if (!table) throw new Error(`Tabela drizzle ausente: ${name}. Rode as migrations.`)
return table
}
const scopeWhere = (table: Record<string, unknown>, scope: ElectionImportScope) =>
@ -107,10 +90,11 @@ export const importElectionScope = async (
payload: Payload,
bundle: ElectionImportBundle,
): Promise<ElectionImportCounts> => {
const dbAdapter = getDb(payload)
const voteTable = requireTable(dbAdapter, VOTE_TABLE)
const tallyTable = requireTable(dbAdapter, TALLY_TABLE)
const candidateTable = requireTable(dbAdapter, CANDIDATE_TABLE)
const tables = getDrizzleTables(payload)
const voteTable = requireTable(tables, VOTE_TABLE)
const tallyTable = requireTable(tables, TALLY_TABLE)
const candidateTable = requireTable(tables, CANDIDATE_TABLE)
const dbAdapter = payload.db as unknown as PayloadDbDrizzle
const winners = computeWinnersByScope(bundle.votes)
const talliesWithWinners = mergeTallyWithWinners(bundle.tallies, winners)

View file

@ -48,9 +48,12 @@ export const acquireTextAdvisoryLocks = async (
if (sortedKeys.length === 0) return
const database = await getPostgresTransactionDatabase(payload, req)
for (const key of sortedKeys) {
await database.execute(
sql`SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))`,
)
}
// Batch every key into a single round trip: unnest() preserves array order for a
// plain function scan, and pg_advisory_xact_lock is volatile (never parallelized),
// so locks are still acquired sequentially in sorted order — same deadlock-avoidance
// guarantee as the previous per-key loop, at 1 round trip instead of N.
const keyFragments = sortedKeys.map((key) => sql`${key}`)
await database.execute(
sql`SELECT pg_advisory_xact_lock(hashtextextended(k, 0)) FROM unnest(ARRAY[${sql.join(keyFragments, sql`, `)}]::text[]) AS k`,
)
}

View file

@ -1,12 +1,20 @@
import 'server-only'
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import type { ConsentDescriptor } from '@/utilities/campaignConsent'
import {
assertDrizzleColumns,
chunk,
drizzleResultRows,
getDrizzleTables,
INSERT_CHUNK_SIZE,
requireTable,
} from '@/utilities/drizzleBulk'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { getPostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks'
import { relationshipId } from '@/utilities/relationship'
export type SupporterImportBulkRow = {
telefone: string
@ -24,40 +32,40 @@ export type SupporterImportBulkResult = {
type DrizzleInsertResult = { rowCount?: number | null }
type DrizzleTx = {
execute: (query: ReturnType<typeof sql>) => Promise<unknown>
insert: (table: unknown) => {
values: (rows: Record<string, unknown>[]) => {
onConflictDoNothing: () => Promise<DrizzleInsertResult>
returning: () => Promise<Array<Record<string, unknown>>>
}
}
}
type DrizzleTables = Record<string, Record<string, unknown>>
type PayloadDb = {
drizzle: unknown
tables: DrizzleTables
}
const CONTACT_TABLE = 'contact'
const SUPPORTER_TABLE = 'supporter'
/** ~16 columns × 500 ≈ 8000 bind params — well under PG 65535. */
const INSERT_CHUNK_SIZE = 500
const chunk = <T>(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))
return out
}
const getTables = (payload: Payload): DrizzleTables =>
(payload.db as unknown as PayloadDb).tables
const requireTable = (tables: DrizzleTables, name: string) => {
const table = tables[name]
if (!table) throw new Error(`Tabela drizzle ausente: ${name}. Rode as migrations.`)
return table
}
/**
* Columns the bulk supporter insert writes (drizzle JS-level names, which omit
* the `Id` suffix Payload relationship fields get at the physical-column level).
* Asserted against the live table scaffold on every import — see `assertDrizzleColumns`.
*/
const SUPPORTER_COLUMNS = [
'contact',
'nucleus',
'voteIntention',
'consent',
'consentContentHash',
'consentedAt',
'voteIntentionConsent',
'voteIntentionConsentContentHash',
'voteIntentionConsentedAt',
'source',
'consentNote',
'notes',
'createdBy',
'createdAt',
'updatedAt',
] as const
const buildContactRows = (
rows: SupporterImportBulkRow[],
@ -109,6 +117,18 @@ const buildSupporterRows = (
updatedAt: args.now,
}))
const asPhoneKeyedRow = (row: Record<string, unknown>): { id: number; phone: string } | null => {
const { id, phone } = row
return typeof id === 'number' && typeof phone === 'string' ? { id, phone } : null
}
const asContactID = (row: Record<string, unknown>): number | null => {
const value = row.contact_id
if (typeof value === 'number') return value
const parsed = Number(value)
return Number.isSafeInteger(parsed) ? parsed : null
}
/**
* Bulk-insert contacts and nucleus-less supporters for a CSV import inside the
* caller's Payload transaction. Phone advisory locks MUST already be held for
@ -118,6 +138,10 @@ const buildSupporterRows = (
* which is safe because the locks guarantee uniqueness within the txn). The
* supporter insert uses `ON CONFLICT DO NOTHING` on the unique
* `(contact_id, nucleus_id)` index as a last-resort guard against races.
*
* All reads (existing contacts, existing supporters) and the ID recovery for
* newly inserted contacts run as raw SQL / `.returning()` on the same drizzle
* transaction session — no `payload.find` round trips through the Local API.
*/
export const bulkInsertSupporterImport = async (args: {
payload: Payload
@ -133,28 +157,32 @@ export const bulkInsertSupporterImport = async (args: {
return { created: 0, skipped: 0, errors: [] }
}
const tables = getTables(payload)
const tables = getDrizzleTables(payload)
const contactTable = requireTable(tables, CONTACT_TABLE)
const supporterTable = requireTable(tables, SUPPORTER_TABLE)
assertDrizzleColumns(supporterTable, SUPPORTER_TABLE, SUPPORTER_COLUMNS)
const database = await getPostgresTransactionDatabase(payload, req)
const tx = database as unknown as DrizzleTx
const now = new Date().toISOString()
const phones = [...new Set(rows.map((row) => row.telefone))]
const existingContacts = await payload.find({
collection: 'contact',
where: { phone: { in: phones } },
depth: 0,
limit: phones.length,
pagination: false,
select: { phone: true },
overrideAccess: true,
req,
})
const contactIdByPhone = new Map(
existingContacts.docs.map((doc) => [doc.phone, doc.id] as const),
)
const contactIdByPhone = new Map<string, number>()
for (const phoneBatch of chunk(phones, INSERT_CHUNK_SIZE)) {
const existingContactRows = drizzleResultRows(
await tx.execute(
sql`SELECT "id", "phone" FROM "contact" WHERE "phone" IN (${sql.join(
phoneBatch.map((phone) => sql`${phone}`),
sql`, `,
)})`,
),
)
for (const row of existingContactRows) {
const parsed = asPhoneKeyedRow(row)
if (parsed) contactIdByPhone.set(parsed.phone, parsed.id)
}
}
// New contacts to create, deduped by phone (preview already dedupes, but this
// is defensive against a stale confirm payload).
@ -168,43 +196,32 @@ export const bulkInsertSupporterImport = async (args: {
if (newContactByPhone.size > 0) {
const newRows = [...newContactByPhone.values()]
for (const batch of chunk(newRows, INSERT_CHUNK_SIZE)) {
await tx.insert(contactTable).values(buildContactRows(batch, now))
}
const newPhones = [...newContactByPhone.keys()]
const createdContacts = await payload.find({
collection: 'contact',
where: { phone: { in: newPhones } },
depth: 0,
limit: newPhones.length,
pagination: false,
select: { phone: true },
overrideAccess: true,
req,
})
for (const doc of createdContacts.docs) {
contactIdByPhone.set(doc.phone, doc.id)
const created = await tx.insert(contactTable).values(buildContactRows(batch, now)).returning()
for (const row of created) {
const inserted = asPhoneKeyedRow(row)
if (inserted) contactIdByPhone.set(inserted.phone, inserted.id)
}
}
}
const contactIDs = [...new Set(contactIdByPhone.values())]
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) => relationshipId(doc.contact))
.filter((id): id is number => id !== null),
)
const supporterContactIDs = new Set<number>()
if (contactIDs.length > 0) {
for (const idBatch of chunk(contactIDs, INSERT_CHUNK_SIZE)) {
const existingSupporterRows = drizzleResultRows(
await tx.execute(
sql`SELECT "contact_id" FROM "supporter" WHERE "contact_id" IN (${sql.join(
idBatch.map((id) => sql`${id}`),
sql`, `,
)}) AND "nucleus_id" IS NULL`,
),
)
for (const row of existingSupporterRows) {
const contactID = asContactID(row)
if (contactID !== null) supporterContactIDs.add(contactID)
}
}
}
const candidateEntries: Array<{ row: SupporterImportBulkRow; contactID: number }> = []
const errors: Array<{ telefone: string; message: string }> = []

View file

@ -4,7 +4,8 @@ import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
import type { CampaignUser } from '@/payload-types'
import { isCampaignGeneral } from '@/utilities/campaignAccess'
import { getCoordinatorNucleusIds, isCampaignGeneral } from '@/utilities/campaignAccess'
import { drizzleResultRows } from '@/utilities/drizzleBulk'
import { normalizeBrazilianPhone } from '@/utilities/phone'
import type { SupporterListState } from '@/utilities/supporterUi'
import type { SupporterListOverviewViewModel } from '@/utilities/supporterViewModels'
@ -18,36 +19,16 @@ type PostgresDb = {
execute: (query: ReturnType<typeof sql>) => Promise<unknown>
}
const resultRows = (result: unknown): Array<Record<string, unknown>> => {
if (Array.isArray(result)) return result as Array<Record<string, unknown>>
if (
typeof result === 'object' &&
result !== null &&
'rows' in result &&
Array.isArray((result as { rows: unknown }).rows)
) {
return (result as { rows: Array<Record<string, unknown>> }).rows
}
return []
}
const resolveAccessConstraint = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
coordinatorNucleusIds?: number[],
): Promise<AccessConstraint> => {
if (isCampaignGeneral(user)) return { kind: 'all' }
if (user.role !== 'coordenador') return { kind: 'none' }
const result = await payload.find({
collection: 'electoralNucleus',
where: { coordinators: { contains: user.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
const ids = result.docs.map((doc) => doc.id)
const ids =
coordinatorNucleusIds ?? (await getCoordinatorNucleusIds(payload, user.id))
return { kind: 'nucleusSet', ids }
}
@ -106,22 +87,35 @@ const buildAggregateSql = (
? sql`FROM "supporter" LEFT JOIN "contact" ON "contact"."id" = "supporter"."contact_id"`
: sql`FROM "supporter"`
// `total` is NOT selected here — the caller already computed it from the
// list query's `totalDocs` (same filters, same access scope), so the
// aggregate only needs to run the two FILTER counts.
return sql`
SELECT
COUNT(*) AS "total",
COUNT(*) FILTER (WHERE "supporter"."vote_intention" IN ('certo', 'tende_a_certo')) AS "certo_and_tende",
COUNT(*) FILTER (WHERE "supporter"."vote_intention" = 'indeciso') AS "indeciso"
${fromClause}${whereClause}
`
}
/**
* Computes the supporter overview KPIs (vote-intention breakdown) for the
* caller's already-resolved list `total` (from `result.totalDocs`, computed
* with the same filters and access scope). Skips the aggregate query entirely
* when `total` is 0, since the panel is hidden in that case anyway.
*/
export const computeSupporterListOverviewAggregate = async (
payload: Payload,
user: CampaignUser,
state: SupporterListState,
total: number,
coordinatorNucleusIds?: number[],
): Promise<SupporterListOverviewViewModel | null> => {
const access = await resolveAccessConstraint(payload, user)
if (!Number.isSafeInteger(total) || total <= 0) return null
const access = await resolveAccessConstraint(payload, user, coordinatorNucleusIds)
if (access.kind === 'none') return null
if (access.kind === 'nucleusSet' && access.ids.length === 0) return null
if (payload.db.name !== 'postgres') {
throw new Error('O overview de apoiadores exige o adaptador PostgreSQL.')
@ -133,12 +127,9 @@ export const computeSupporterListOverviewAggregate = async (
}
const result = await drizzle.execute(buildAggregateSql(state, access))
const row = resultRows(result)[0]
const row = drizzleResultRows(result)[0]
if (!row) return null
const total = Number(row.total)
if (!Number.isSafeInteger(total) || total === 0) return null
return {
total,
certoAndTende: Number(row.certo_and_tende ?? 0),

View file

@ -106,8 +106,10 @@ export const loadSupporterListOverviewData = async (
payload: Payload,
user: CampaignUser,
state: SupporterListState,
total: number,
coordinatorNucleusIds?: number[],
): Promise<SupporterListOverviewViewModel | null> =>
computeSupporterListOverviewAggregate(payload, user, state)
computeSupporterListOverviewAggregate(payload, user, state, total, coordinatorNucleusIds)
export const loadSupporterDetailPageData = async (
payload: Pick<Payload, 'find'>,

View file

@ -395,11 +395,20 @@ describe('campaign supporter domain', () => {
})
}
const overview = await loadSupporterListOverviewData(payload, geral, { page: 1 })
const overview = await loadSupporterListOverviewData(
payload,
geral,
{ page: 1 },
intentions.length,
)
expect(overview).not.toBeNull()
expect(overview!.total).toBe(intentions.length)
expect(overview!.certoAndTende).toBe(3)
expect(overview!.indeciso).toBe(2)
// `total` is the caller's contract: a 0 total skips the aggregate query
// entirely and hides the panel, even if rows technically exist.
expect(await loadSupporterListOverviewData(payload, geral, { page: 1 }, 0)).toBeNull()
})
it('scopes the overview aggregate to accessible nuclei for coordenador and applies filters', async () => {
@ -432,16 +441,18 @@ describe('campaign supporter domain', () => {
await makeSupporter(assigned.id, null, 'Feira de Santana')
await makeSupporter(other.id, 'certo') // outside scope — must not be counted
const overview = await loadSupporterListOverviewData(payload, coordenador, { page: 1 })
const overview = await loadSupporterListOverviewData(payload, coordenador, { page: 1 }, 3)
expect(overview).not.toBeNull()
expect(overview!.total).toBe(3)
expect(overview!.certoAndTende).toBe(1)
expect(overview!.indeciso).toBe(1)
const filtered = await loadSupporterListOverviewData(payload, coordenador, {
page: 1,
city: 'Feira de Santana',
})
const filtered = await loadSupporterListOverviewData(
payload,
coordenador,
{ page: 1, city: 'Feira de Santana' },
1,
)
expect(filtered).not.toBeNull()
expect(filtered!.total).toBe(1)
})

View file

@ -20,6 +20,22 @@ import {
let payload: Payload
/**
* Recursively flattens a drizzle-orm `SQL` object's `queryChunks` down to the
* raw string bind params (skipping the `StringChunk` text fragments), so the
* mocked test below can assert which keys were sent in a single round trip.
*/
const collectLockParams = (query: unknown): string[] => {
const chunks = (query as { queryChunks?: unknown[] }).queryChunks ?? []
return chunks.flatMap((chunk): string[] => {
if (typeof chunk === 'string') return [chunk]
if (chunk && typeof chunk === 'object' && 'queryChunks' in chunk) {
return collectLockParams(chunk)
}
return []
})
}
const beginTransaction = async (): Promise<{
req: { transactionID: number | string }
transaction: PostgresTransactionDatabase
@ -76,12 +92,10 @@ describe('PostgreSQL transaction advisory locks', () => {
'domain:1',
'domain:1',
])
expect(sessionDatabase.execute).toHaveBeenCalledTimes(2)
expect(
sessionDatabase.execute.mock.calls.map(
([query]) => (query as { queryChunks: unknown[] }).queryChunks[1],
),
).toEqual(['domain:1', 'domain:2'])
// Batched: all (deduplicated, sorted) keys go out in exactly one round trip.
expect(sessionDatabase.execute).toHaveBeenCalledTimes(1)
const [[query]] = sessionDatabase.execute.mock.calls
expect(collectLockParams(query)).toEqual(['domain:1', 'domain:2'])
expect(unrelatedDatabase.execute).not.toHaveBeenCalled()
await expect(getPostgresTransactionDatabase(fixture as never, {})).rejects.toThrow(