Merge cursor/37d8d87f: simplify C6 supporter paths and register C8 scale/DRY follow-ups

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-18 22:35:37 -03:00
commit fb0127ba28
41 changed files with 10808 additions and 686 deletions

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-C2 (apoiadores + listas)
Status: rascunho
Atualizado em: 2026-07-18
Status: implementado (Fases 1–5)
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C6)
Responsável: —
@ -121,3 +121,38 @@ flowchart TD
- `src/collections/Contact.ts` — `enforceUniqueContactPhone`
- `scripts/seed-tse-results.mjs` — bulk drizzle em txn
- AGENTS.md — `overrideAccess: false`, transações, Contact + locks, naming
## Implementação (2026-07-19)
Todas as cinco fases foram implementadas e validadas localmente (`tsc --noEmit`, `pnpm lint`, `pnpm test:int`, `pnpm test:unit`) contra `teqo_test`. Sem regressões nas rotas de núcleos/apoiadores/planos já entregues.
### Fase 1 — UX alinhada
- `SupporterForm` agora usa `StrictCombobox` + `municipalityComboboxOptions()` no município (validação continua em `optionalBahiaCity`).
- `RemoveSupporterDataButton` trocou `window.confirm` por `AlertDialog` no padrão de `ArchiveNucleusDialog.tsx`.
### Fase 2 — DRY com núcleos / leadership
- `src/utilities/campaignListUrl.ts` (novo): `firstValue`, `normalizedText`, `strictDecimalInteger`, `inspectRawListParams`, `resolveListUrl`, `buildListHref`. `nucleusUi.ts`, `supporterUi.ts` e `actionPlanUi.ts` delegam para ele (domínio permanece nos módulos de domínio).
- `src/components/campaign/CampaignListPagination.tsx` (novo) + `getPaginationPages`: substitui `NucleusPagination`, `SupporterPagination` e `ActionPlanPagination` (os três foram removidos). Listagens de núcleos/apoiadores/planos e o teste unitário `campaignNucleusUi` apontam para o novo componente.
- `src/utilities/campaignFormFields.ts` (novo): `fieldError` / `errorProps` compartilhados por `SupporterForm` e `LeadershipForm` (cada form liga um `errorProps` local ao seu prefixo de id).
- `src/utilities/campaignFormActionError.ts` (novo): `mapCampaignFormActionError` consolida FormDataBoundaryError → Zod → safelist → genérico. `apoiadores/novo/formActions.ts`, `apoiadores/[id]/formActions.ts` e `nucleos/[slug]/leadershipFormActions.ts` usam o mapper.
### Fase 3 — KPI da lista
- `src/utilities/supporterListOverviewAggregate.ts` (novo): `computeSupporterListOverviewAggregate` faz um único `SELECT COUNT(*) FILTER (...)` via drizzle na sessão da txn Payload, espelhando `buildSupporterListWhere` + a constraint de acesso (`nucleus_id IN (...)` para coordenador, sem constraint para geral, `none` para liderança). `loadSupporterListOverviewData` passou de 3× `payload.count` para um round-trip. Testes int cobrem geral, coordenador (escopo + filtro de cidade) e base vazia.
### Fase 4 — Import em escala
- `src/utilities/supporterImportBulk.ts` (novo): `bulkInsertSupporterImport` insere contacts e supporters via `payload.db.drizzle` na sessão da txn Payload (`getPostgresTransactionDatabase`), em chunks de 500, com `ON CONFLICT DO NOTHING` no índice unique `(contact_id, nucleus_id)`. Substitui até 10k `payload.create` por poucos inserts bulk.
- `src/collections/Contact.ts`: `enforceUniqueContactPhone` honra `context.skipContactPhoneInvariant` **fail-closed** — só respeita quando `req.transactionID` está ativo (sem txn, os locks xact-level não podem estar segurados → throw). `upsertContactByPhone` passa o context (o caller já segurou os locks), eliminando o re-lock/re-check redundante do path single-create.
### Fase 5 — Preview sem round-trip
- `src/collections/SupporterImportBatch.ts` (novo, admin hidden, acesso `geral`/admin) + migration `20260719_011015_add_supporter_import_batch`: staging efêmero do conjunto `ok` no servidor.
- `src/utilities/supporterImportToken.ts` (novo): token HMAC-SHA256(`${batchId}.${actorID}.${expiresAt}`, PAYLOAD_SECRET) com `timingSafeEqual` e TTL 10 min. `previewSupporterImportText` agora retorna `{ counts, sampleRows (100), errorReportCsv, importToken }` e estageia o conjunto `ok` em `supporterImportBatch`. `confirmSupporterImportRecord` aceita `importToken` (schema trocou `rows` por `importToken`), verifica HMAC + ator + expiração, consome o lote (single-use, delete após commit) e alimenta o bulk insert. O wizard parou de guardar/reenviar as rows.
- Testes int: fluxo preview→confirm; token tampered (assinatura alterada) rejeitado; token de outro ator rejeitado; reuso de token consumido rejeitado.
## Achados da auditoria (decisões de implementação)
- **Nomes de coluna drizzle para relationships NÃO levam sufixo `Id`.** Em `payload.db.tables.supporter`, as colunas de relationship são `contact`, `nucleus`, `consent`, `voteIntentionConsent`, `createdBy` — não `contactId`, etc. O insert bulk inicial usou `contactId`/`createdById` e o drizzle silenciou essas chaves (gerou `default` → violação NOT NULL). Corrigido para os nomes sem sufixo. Lição para C7 e futuros inserts bulk: inspecionar `Object.keys(payload.db.tables.<slug>)` antes de montar as rows.
- **`payload-preferences` não serve de staging genérico.** Tem campo `user` (relationship users/campaignUser) obrigatório e validado em `beforeChange`, e semântica de preferência por usuário. Tentar usá-lo para o lote de importação falhou com `ValidationError: User`. Decidido pela tabela dedicada `supporterImportBatch` (migration), como o plano já previa como fallback. Não há Redis novo no v1 do C6.
- **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).

View file

@ -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<br/>locks em 1 RT · .returning · drizzle reads"]
F2["Fase 2 — Perf lista<br/>1 COUNT · trgm · memo accessible nuclei"]
F3["Fase 3 — DRY helpers<br/>drizzleBulk · getAccessibleNucleusIds variant · DrizzleTx assertion"]
F4["Fase 4 — DRY forms<br/>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

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6/C7 registrados — escala/DRY pós-C2 e 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<br/>(import/listas/forms)"]
C3["C3 Eventos / agenda de mobilização ✓"]
C7["C7 Escala e DRY pós-C3<br/>(território/contato/leituras)"]
C8["C8 Escala e DRY pós-C6<br/>(locks bulk / leituras / forms DRY)"]
C4["C4 Demandas"]
C5["C5 Operação dia D / GOTV<br/>(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
@ -184,30 +187,31 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- |
| 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) | [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 |
| 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, 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)

View file

@ -1,17 +1,15 @@
'use server'
import { revalidatePath } from 'next/cache'
import { ZodError } from 'zod'
import { createSupporter } from '@/app/(campaign)/campanha/actions/supporter'
import {
checkboxFormValue,
FormDataBoundaryError,
nullableRelationshipFormValue,
optionalFormText,
validationFieldErrors,
} from '@/lib/formData'
import { supporterCreateSchema } from '@/lib/schemas/supporter'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import { sanitizeBrazilianPhoneInput } from '@/utilities/phone'
export type SupporterFormState = {
@ -45,25 +43,14 @@ const getSupporterFormError = (
error: unknown,
values?: SupporterFormValues,
revision?: number,
): SupporterFormState => {
if (error instanceof FormDataBoundaryError) {
return { fieldErrors: { [error.field]: [error.message] }, values, revision }
}
if (error instanceof ZodError) {
return { fieldErrors: validationFieldErrors(error), values, revision }
}
if (
error instanceof Error &&
safeActionMessages.includes(error.message as (typeof safeActionMessages)[number])
) {
return { message: error.message, values, revision }
}
return {
message: 'Não foi possível cadastrar o apoiador. Verifique os dados e tente novamente.',
): SupporterFormState =>
mapCampaignFormActionError({
error,
safeMessages: safeActionMessages,
genericMessage: 'Não foi possível cadastrar o apoiador. Verifique os dados e tente novamente.',
values,
revision,
}
}
})
export const createSupporterFormAction = async (
state: SupporterFormState,

View file

@ -4,11 +4,11 @@ import { redirect } from 'next/navigation'
import config from '@payload-config'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { SupporterFilters } from '@/components/campaign/SupporterFilters'
import { SupporterList } from '@/components/campaign/SupporterList'
import { SupporterListOverview } from '@/components/campaign/SupporterListOverview'
import { SupporterPagination } from '@/components/campaign/SupporterPagination'
import { Button } from '@/components/ui/button'
import {
Empty,
@ -28,6 +28,7 @@ import {
import { toSupporterListItemViewModel } from '@/utilities/supporterViewModels'
import {
buildSupporterFiltersKey,
buildSupporterListHref,
canAccessSupporterArea,
getSupporterScopeLabel,
resolveSupporterListUrl,
@ -101,7 +102,11 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
{result.totalDocs}{' '}
{result.totalDocs === 1 ? 'apoiador encontrado' : 'apoiadores encontrados'}
</p>
<SupporterPagination state={state} totalPages={result.totalPages} />
<CampaignListPagination
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildSupporterListHref(state, page)}
/>
</div>
</>
) : (

View file

@ -1,7 +1,6 @@
'use server'
import { revalidatePath } from 'next/cache'
import { ZodError } from 'zod'
import {
createLeadership,
@ -14,8 +13,8 @@ import {
nullableFormText,
optionalFormText,
requiredRelationshipFormValue,
validationFieldErrors,
} from '@/lib/formData'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
export type LeadershipFormState = {
status?: 'success'
@ -46,35 +45,26 @@ const safeActionMessages = [
'Somente a coordenação pode gerenciar lideranças.',
] as const
const resolveLeadershipBoundaryMessage = (error: FormDataBoundaryError): string =>
error.field === 'contact'
? 'Não foi possível identificar a liderança. Atualize a página e tente novamente.'
: error.field === 'id' || error.field === 'nucleus'
? 'Não foi possível identificar o registro. Atualize a página e tente novamente.'
: error.message
const getLeadershipFormError = (
error: unknown,
values?: LeadershipFormValues,
revision?: number,
): LeadershipFormState => {
if (error instanceof FormDataBoundaryError) {
const message =
error.field === 'contact'
? 'Não foi possível identificar a liderança. Atualize a página e tente novamente.'
: error.field === 'id' || error.field === 'nucleus'
? 'Não foi possível identificar o registro. Atualize a página e tente novamente.'
: error.message
return { fieldErrors: { [error.field]: [message] }, values, revision }
}
if (error instanceof ZodError) {
return { fieldErrors: validationFieldErrors(error), values, revision }
}
if (
error instanceof Error &&
safeActionMessages.includes(error.message as (typeof safeActionMessages)[number])
) {
return { message: error.message, values, revision }
}
return {
message: 'Não foi possível salvar a liderança. Verifique os dados e tente novamente.',
): LeadershipFormState =>
mapCampaignFormActionError({
error,
safeMessages: safeActionMessages,
genericMessage: 'Não foi possível salvar a liderança. Verifique os dados e tente novamente.',
values,
revision,
}
}
resolveBoundaryMessage: resolveLeadershipBoundaryMessage,
})
export const createLeadershipFormAction = async (
state: LeadershipFormState,

View file

@ -6,10 +6,10 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { NucleusFilters } from '@/components/campaign/NucleusFilters'
import { NucleusList } from '@/components/campaign/NucleusList'
import { NucleusListOverview } from '@/components/campaign/NucleusListOverview'
import { NucleusPagination } from '@/components/campaign/NucleusPagination'
import { Button } from '@/components/ui/button'
import {
Empty,
@ -24,6 +24,7 @@ import { loadNucleusListOverviewData } from '@/utilities/nucleusListOverviewPage
import { loadNucleusListPageData } from '@/utilities/nucleusPageData'
import {
buildNucleusFiltersKey,
buildNucleusListHref,
getCampaignScopeLabel,
resolveNucleusListUrl,
} from '@/utilities/nucleusUi'
@ -89,7 +90,11 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) {
{result.totalDocs}{' '}
{result.totalDocs === 1 ? 'núcleo encontrado' : 'núcleos encontrados'}
</p>
<NucleusPagination state={state} totalPages={result.totalPages} />
<CampaignListPagination
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildNucleusListHref(state, page)}
/>
</div>
</>
) : (

View file

@ -6,7 +6,7 @@ import { getPayload } from 'payload'
import { ActionPlanFilters } from '@/components/campaign/ActionPlanFilters'
import { ActionPlanList } from '@/components/campaign/ActionPlanList'
import { ActionPlanPagination } from '@/components/campaign/ActionPlanPagination'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { Button } from '@/components/ui/button'
import {
Empty,
@ -19,7 +19,7 @@ import {
import type { ActionPlan } from '@/payload-types'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadActionPlanListPageData } from '@/utilities/actionPlanPageData'
import { buildActionPlanFiltersKey, resolveActionPlanListUrl } from '@/utilities/actionPlanUi'
import { buildActionPlanFiltersKey, buildActionPlanListHref, resolveActionPlanListUrl } from '@/utilities/actionPlanUi'
import { toActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
type ActionPlanListPageProps = {
@ -71,7 +71,11 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
<p className="text-sm text-muted-foreground">
{result.totalDocs} {result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}
</p>
<ActionPlanPagination state={state} totalPages={result.totalPages} />
<CampaignListPagination
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildActionPlanListHref(state, page)}
/>
</div>
</>
) : (

View file

@ -23,7 +23,16 @@ import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { normalizeBrazilianPhone } from '@/utilities/phone'
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
import { bulkInsertSupporterImport } from '@/utilities/supporterImportBulk'
import {
issueSupporterImportToken,
loadSupporterImportBatch,
deleteSupporterImportBatch,
storeSupporterImportBatch,
verifySupporterImportToken,
} from '@/utilities/supporterImportToken'
import {
isPreviewErrorRow,
isSupporterImportOkRow,
type SupporterImportPreviewResult,
type SupporterImportPreviewRow,
@ -39,6 +48,23 @@ export type {
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 escapeCsvCell = (value: string): string => `"${value.replace(/"/g, '""')}"`
const buildSupporterImportErrorReportCsv = (rows: SupporterImportPreviewRow[]): string => {
const header = 'linha,nome,telefone,municipio,intencao,status\n'
const body = rows
.filter(isPreviewErrorRow)
.map(
(row) =>
`${row.line},${escapeCsvCell(row.nome)},${escapeCsvCell(row.telefone)},${escapeCsvCell(row.municipio)},${escapeCsvCell(row.intencao)},${row.status}`,
)
.join('\n')
return header + body
}
const getFreshStaffActor = async (
payload: Payload,
actor: CampaignUser,
@ -149,6 +175,10 @@ const upsertContactByPhone = async ({
},
depth: 0,
overrideAccess: true,
// Callers of upsertContactByPhone have already acquired the phone advisory
// lock in the same transaction, so the Contact phone-invariant hook can
// skip its redundant lock+availability check.
context: { skipContactPhoneInvariant: true },
req,
})
@ -333,7 +363,7 @@ export const previewSupporterImportText = async (
actor: CampaignUser,
csvText: string,
): Promise<SupporterImportPreviewResult> => {
await getFreshGeneralActor(payload, actor)
const currentActor = await getFreshGeneralActor(payload, actor)
await requireSupporterRegistrationConsent(payload)
await requireSupporterVoteIntentionConsent(payload)
@ -490,7 +520,31 @@ export const previewSupporterImportText = async (
.length,
}
return { rows, counts }
const okRows = rows.filter(isSupporterImportOkRow).map((row) => ({
nome: row.nome,
telefone: row.normalizedPhone,
municipio: row.canonicalCity,
intencao: row.voteIntention,
}))
const errorReportCsv = buildSupporterImportErrorReportCsv(rows)
const sampleRows = rows.slice(0, SUPPORTER_IMPORT_SAMPLE_SIZE)
// Stage the full ok set server-side keyed by an HMAC-signed token so the wizard
// never has to hold/re-send thousands of rows across the Server Action boundary.
const issued = issueSupporterImportToken(currentActor.id)
await storeSupporterImportBatch(payload, issued.batchId, {
actorID: currentActor.id,
expiresAt: issued.expiresAt,
okRows,
})
return {
counts,
sampleRows,
errorReportCsv,
importToken: issued.token,
}
}
export const previewSupporterImport = async (csvText: string) => {
@ -505,6 +559,14 @@ export const confirmSupporterImportRecord = async (
) => {
const data = supporterImportConfirmSchema.parse(input)
// Verify the HMAC token and load the staged batch BEFORE opening the write
// transaction, so an invalid/expired token is rejected without holding locks.
const verified = verifySupporterImportToken(data.importToken, actor.id)
const batch = await loadSupporterImportBatch(payload, verified.batchId, actor.id)
if (batch.okRows.length === 0) {
throw new Error('O lote de importação não contém apoiadores válidos.')
}
return withPayloadTransaction(
payload,
async ({ req }) => {
@ -516,111 +578,26 @@ export const confirmSupporterImportRecord = async (
throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.')
}
const phones = [...new Set(data.rows.map((row) => row.telefone))]
const phones = [...new Set(batch.okRows.map((row) => row.telefone))]
await acquireContactPhoneLocks(payload, req, phones)
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),
)
for (const row of data.rows) {
if (contactIdByPhone.has(row.telefone)) continue
const { contactID } = await upsertContactByPhone({
payload,
req,
phone: row.telefone,
name: row.nome,
city: row.municipio,
})
contactIdByPhone.set(row.telefone, contactID)
}
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),
)
let created = 0
let skipped = 0
const errors: Array<{ telefone: string; message: string }> = []
const consentNote =
data.consentNote ??
'Importação CSV com atestado do operador sobre consentimento dos titulares.'
const consentedAt = new Date().toISOString()
for (const row of data.rows) {
const contactID = contactIdByPhone.get(row.telefone)
if (!contactID) {
skipped += 1
errors.push({ telefone: row.telefone, message: 'Contato não encontrado após upsert.' })
continue
}
const result = await bulkInsertSupporterImport({
payload,
req,
actorID: currentActor.id,
rows: batch.okRows,
registrationConsent,
voteIntentionConsent,
consentNote,
})
if (supporterContactIDs.has(contactID)) {
skipped += 1
continue
}
try {
await payload.create({
collection: 'supporter',
data: {
contact: contactID,
source: 'import_csv',
voteIntention: row.intencao,
consent: registrationConsent.id,
consentContentHash: registrationConsent.contentHash,
consentedAt,
consentNote,
...(row.intencao
? {
voteIntentionConsent: voteIntentionConsent.id,
voteIntentionConsentContentHash: voteIntentionConsent.contentHash,
voteIntentionConsentedAt: consentedAt,
}
: {}),
createdBy: currentActor.id,
},
depth: 0,
overrideAccess: true,
req,
})
supporterContactIDs.add(contactID)
created += 1
} catch (error) {
skipped += 1
errors.push({
telefone: row.telefone,
message: error instanceof Error ? error.message : 'Erro ao importar linha.',
})
}
}
return { created, skipped, errors }
// Single-use token: drop the staged batch once the import commits.
await deleteSupporterImportBatch(payload, verified.batchId, req)
return result
},
{ beginFailureMessage: 'Não foi possível iniciar a transação de importação de apoiadores.' },
)

View file

@ -17,6 +17,20 @@ const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({
originalDoc,
req,
}) => {
// Opt-out for callers that have already acquired the phone advisory locks in the
// SAME Payload transaction (e.g. bulk import). Fail closed: the flag is only
// 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 == null) {
throw new APIError(
'skipContactPhoneInvariant exige uma transação ativa com os locks de telefone já adquiridos.',
500,
)
}
return data
}
const phone = String(data.phone ?? originalDoc?.phone ?? '')
const oldPhone =
operation === 'update' && typeof originalDoc?.phone === 'string'

View file

@ -0,0 +1,67 @@
import type { CollectionConfig } from 'payload'
import { canManageCampaignUsers } from '@/utilities/campaignAccess'
/**
* Short-lived server-side staging for the supporter CSV import wizard (roadmap
* C6, Phase 5). The preview stores the full `ok` row set here keyed by an
* HMAC-signed token; the confirm step redeems the token, consumes the batch,
* and bulk-inserts. This keeps thousands of rows from crossing the Server
* Action boundary twice.
*
* The collection is hidden from the admin nav and restricted to `geral` /
* 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).
*/
export const SupporterImportBatch: CollectionConfig = {
slug: 'supporterImportBatch',
labels: {
singular: 'Lote de importação',
plural: 'Lotes de importação',
},
admin: {
group: 'Campanha',
hidden: () => true,
useAsTitle: 'batchId',
defaultColumns: ['batchId', 'actor', 'expiresAt'],
},
access: {
create: canManageCampaignUsers,
read: canManageCampaignUsers,
update: canManageCampaignUsers,
delete: canManageCampaignUsers,
},
fields: [
{
name: 'batchId',
type: 'text',
label: 'ID do lote',
required: true,
unique: true,
index: true,
},
{
name: 'actor',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Operador',
required: true,
index: true,
},
{
name: 'expiresAt',
type: 'date',
label: 'Expira em',
required: true,
index: true,
},
{
name: 'rows',
type: 'json',
label: 'Linhas válidas',
required: true,
},
],
}

View file

@ -1,61 +0,0 @@
import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/Pagination'
import { buildActionPlanListHref, type ActionPlanListState } from '@/utilities/actionPlanUi'
export const ActionPlanPagination = ({
state,
totalPages,
}: {
state: ActionPlanListState
totalPages: number
}) => {
if (totalPages <= 1) return null
const visiblePages = getNucleusPaginationPages(state.page, totalPages)
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={buildActionPlanListHref(state, Math.max(1, state.page - 1))}
aria-disabled={state.page <= 1}
className={state.page <= 1 ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
{visiblePages.map((page, index) => {
const previousPage = visiblePages[index - 1]
const hasGap = previousPage !== undefined && page - previousPage > 1
return (
<PaginationItem key={page} className="contents">
{hasGap ? <PaginationEllipsis /> : null}
<PaginationLink
href={buildActionPlanListHref(state, page)}
isActive={page === state.page}
aria-label={`Ir para a página ${page}`}
>
{page}
</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
href={buildActionPlanListHref(state, Math.min(totalPages, state.page + 1))}
aria-disabled={state.page >= totalPages}
className={state.page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}

View file

@ -0,0 +1,68 @@
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/Pagination'
export const getPaginationPages = (page: number, totalPages: number): number[] => {
const pages = new Set([1, totalPages, page - 1, page, page + 1])
return [...pages]
.filter((candidate) => candidate >= 1 && candidate <= totalPages)
.sort((a, b) => a - b)
}
export const CampaignListPagination = ({
page,
totalPages,
hrefForPage,
}: {
page: number
totalPages: number
hrefForPage: (page: number) => string
}) => {
if (totalPages <= 1) return null
const visiblePages = getPaginationPages(page, totalPages)
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={hrefForPage(Math.max(1, page - 1))}
aria-disabled={page <= 1}
className={page <= 1 ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
{visiblePages.map((pageNumber, index) => {
const previousPage = visiblePages[index - 1]
const hasGap = previousPage !== undefined && pageNumber - previousPage > 1
return (
<PaginationItem key={pageNumber} className="contents">
{hasGap ? <PaginationEllipsis /> : null}
<PaginationLink
href={hrefForPage(pageNumber)}
isActive={pageNumber === page}
aria-label={`Ir para a página ${pageNumber}`}
>
{pageNumber}
</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
href={hrefForPage(Math.min(totalPages, page + 1))}
aria-disabled={page >= totalPages}
className={page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}

View file

@ -27,6 +27,7 @@ import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { leadershipGenderLabels, leadershipSectorLabels } from '@/utilities/leadershipUi'
import type { LeadershipEditViewModel } from '@/utilities/leadershipViewModels'
import { fieldError, errorProps as buildErrorProps } from '@/utilities/campaignFormFields'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
export type LeadershipFormAction = (
@ -49,19 +50,10 @@ const supportStatusOptions = [
{ value: 'negativo', label: 'Negativo' },
] as const
const fieldError = (
const errorProps = (
fieldErrors: Record<string, string[]> | undefined,
field: string,
): string | undefined => fieldErrors?.[field]?.[0]
const errorProps = (fieldErrors: Record<string, string[]> | undefined, field: string) => {
const error = fieldError(fieldErrors, field)
return {
error,
invalid: Boolean(error),
describedBy: error ? `leadership-${field}-error` : undefined,
}
}
) => buildErrorProps(fieldErrors, field, 'leadership')
export const LeadershipFormFeedback = ({
message,

View file

@ -1,67 +0,0 @@
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/Pagination'
import { buildNucleusListHref, type NucleusListState } from '@/utilities/nucleusUi'
export const getNucleusPaginationPages = (page: number, totalPages: number): number[] => {
const pages = new Set([1, totalPages, page - 1, page, page + 1])
return [...pages]
.filter((candidate) => candidate >= 1 && candidate <= totalPages)
.sort((a, b) => a - b)
}
export const NucleusPagination = ({
state,
totalPages,
}: {
state: NucleusListState
totalPages: number
}) => {
if (totalPages <= 1) return null
const visiblePages = getNucleusPaginationPages(state.page, totalPages)
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={buildNucleusListHref(state, Math.max(1, state.page - 1))}
aria-disabled={state.page <= 1}
className={state.page <= 1 ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
{visiblePages.map((page, index) => {
const previousPage = visiblePages[index - 1]
const hasGap = previousPage !== undefined && page - previousPage > 1
return (
<PaginationItem key={page} className="contents">
{hasGap ? <PaginationEllipsis /> : null}
<PaginationLink
href={buildNucleusListHref(state, page)}
isActive={page === state.page}
aria-label={`Ir para a página ${page}`}
>
{page}
</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
href={buildNucleusListHref(state, Math.min(totalPages, state.page + 1))}
aria-disabled={state.page >= totalPages}
className={state.page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}

View file

@ -5,6 +5,16 @@ import { useActionState } from 'react'
import type { SupporterRemoveFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
import { removeSupporterDataFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/AlertDialog'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
@ -21,32 +31,39 @@ export const RemoveSupporterDataButton = ({ supporterId }: { supporterId: number
Exercício do direito de eliminação (LGPD art. 18). Remove o cadastro de apoiador e anonimiza
o contato quando não houver outros vínculos.
</p>
{state.message ? (
<Alert variant="destructive" className="mt-4">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<form action={formAction} className="mt-4">
<input type="hidden" name="id" value={supporterId} />
<Button
type="submit"
variant="destructive"
className="min-h-11"
disabled={pending}
onClick={(event) => {
if (
!window.confirm(
'Confirma a remoção dos dados deste apoiador? Esta ação não pode ser desfeita.',
)
) {
event.preventDefault()
}
}}
>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Removendo…' : 'Remover dados'}
</Button>
</form>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" variant="destructive" className="mt-4 min-h-11">
Remover dados
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remover os dados deste apoiador?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. O cadastro de apoiador será removido e o contato
anonimizado quando não houver outros vínculos.
</AlertDialogDescription>
</AlertDialogHeader>
{state.message ? (
<Alert variant="destructive" aria-live="polite">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<form action={formAction}>
<input type="hidden" name="id" value={supporterId} />
<AlertDialogFooter>
<AlertDialogCancel type="button" className="min-h-11" disabled={pending}>
Cancelar
</AlertDialogCancel>
<Button type="submit" variant="destructive" className="min-h-11" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Removendo…' : 'Confirmar remoção'}
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
</section>
)
}

View file

@ -6,6 +6,7 @@ import { toast } from 'sonner'
import type { SupporterFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
import { FormattedInput } from '@/components/FormattedInput'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
@ -22,28 +23,21 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import { fieldError, errorProps as buildErrorProps } from '@/utilities/campaignFormFields'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
import type { SupporterNucleusOption } from '@/utilities/supporterViewModels'
import { municipalityComboboxOptions } from '@/utilities/territoryComboboxOptions'
export type SupporterFormAction = (
state: SupporterFormState,
formData: FormData,
) => Promise<SupporterFormState>
const fieldError = (
const errorProps = (
fieldErrors: Record<string, string[]> | undefined,
field: string,
): string | undefined => fieldErrors?.[field]?.[0]
const errorProps = (fieldErrors: Record<string, string[]> | undefined, field: string) => {
const error = fieldError(fieldErrors, field)
return {
error,
invalid: Boolean(error),
describedBy: error ? `supporter-${field}-error` : undefined,
}
}
) => buildErrorProps(fieldErrors, field, 'supporter')
const ConsentBlock = ({
id,
@ -115,6 +109,7 @@ export const SupporterForm = ({
const [voteIntention, setVoteIntention] = useState<SupporterVoteIntention | ''>(
(state.values?.voteIntention as SupporterVoteIntention | undefined) ?? '',
)
const [city, setCity] = useState<string>((state.values?.city as string | undefined) ?? '')
const values = state.values
useEffect(() => {
@ -204,13 +199,13 @@ export const SupporterForm = ({
<Field data-invalid={cityField.invalid}>
<FieldLabel htmlFor="supporter-city">Município</FieldLabel>
<Input
<input type="hidden" name="city" value={city} />
<StrictCombobox
id="supporter-city"
name="city"
defaultValue={values?.city}
aria-invalid={cityField.invalid}
aria-describedby={cityField.describedBy}
className="min-h-11 rounded-[6px]"
options={municipalityComboboxOptions()}
value={city}
onValueChange={setCity}
error={cityField.error}
/>
{cityField.error ? (
<FieldError id="supporter-city-error">{cityField.error}</FieldError>

View file

@ -29,7 +29,7 @@ import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { cn } from '@/lib/utils'
import {
isSupporterImportOkRow,
isPreviewErrorRow,
type SupporterImportPreviewResult,
type SupporterImportPreviewRow,
} from '@/utilities/supporterImport'
@ -46,19 +46,8 @@ const importStatusLabels: Record<SupporterImportPreviewRow['status'], string> =
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 = (rows: SupporterImportPreviewRow[]) => {
const errorRows = rows.filter(isPreviewErrorRow)
const header = 'linha,nome,telefone,municipio,intencao,status\n'
const body = errorRows
.map(
(row) =>
`${row.line},"${row.nome.replace(/"/g, '""')}","${row.telefone}","${row.municipio}","${row.intencao}",${row.status}`,
)
.join('\n')
const blob = new Blob([header + body], { type: 'text/csv;charset=utf-8' })
const downloadErrorReport = (errorReportCsv: string) => {
const blob = new Blob([errorReportCsv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
@ -99,24 +88,19 @@ export const SupporterImportWizard = () => {
}
const visibleRows =
preview?.rows.filter((row) => (previewFilter === 'errors' ? isPreviewErrorRow(row) : true)) ??
[]
preview?.sampleRows.filter((row) =>
previewFilter === 'errors' ? isPreviewErrorRow(row) : true,
) ?? []
const confirmImport = () => {
if (!preview) return
setError(undefined)
startTransition(async () => {
try {
const rows = preview.rows.filter(isSupporterImportOkRow).map((row) => ({
nome: row.nome,
telefone: row.normalizedPhone,
municipio: row.canonicalCity,
intencao: row.voteIntention,
}))
const result = await confirmSupporterImport({
operatorAttested: true,
consentNote: consentNote.trim() || undefined,
rows,
importToken: preview.importToken,
})
setConfirmResult({ created: result.created, skipped: result.skipped })
setStep(2)
@ -210,7 +194,7 @@ export const SupporterImportWizard = () => {
type="button"
variant="outline"
className="min-h-11"
onClick={() => downloadErrorReport(preview.rows)}
onClick={() => downloadErrorReport(preview.errorReportCsv)}
>
Baixar relatório de erros
</Button>

View file

@ -1,61 +0,0 @@
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/Pagination'
import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination'
import { buildSupporterListHref, type SupporterListState } from '@/utilities/supporterUi'
export const SupporterPagination = ({
state,
totalPages,
}: {
state: SupporterListState
totalPages: number
}) => {
if (totalPages <= 1) return null
const visiblePages = getNucleusPaginationPages(state.page, totalPages)
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={buildSupporterListHref(state, Math.max(1, state.page - 1))}
aria-disabled={state.page <= 1}
className={state.page <= 1 ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
{visiblePages.map((page, index) => {
const previousPage = visiblePages[index - 1]
const hasGap = previousPage !== undefined && page - previousPage > 1
return (
<PaginationItem key={page} className="contents">
{hasGap ? <PaginationEllipsis /> : null}
<PaginationLink
href={buildSupporterListHref(state, page)}
isActive={page === state.page}
aria-label={`Ir para a página ${page}`}
>
{page}
</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
href={buildSupporterListHref(state, Math.min(totalPages, state.page + 1))}
aria-disabled={state.page >= totalPages}
className={state.page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}

View file

@ -79,17 +79,7 @@ export const supporterImportConfirmSchema = z.object({
error: 'É necessário atestar o consentimento dos apoiadores importados.',
}),
consentNote: trimmedNullableText(2000),
rows: z
.array(
z.object({
nome: z.string().trim().min(2).max(120),
telefone: brazilianMobile,
municipio: optionalBahiaCity,
intencao: z.enum(supporterVoteIntentions).optional(),
}),
)
.min(1)
.max(5000),
importToken: z.string().min(1, { error: 'Token de importação ausente.' }),
})
export const supporterRemoveSchema = z.object({

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Adds the `supporterImportBatch` staging collection (roadmap C6, Phase 5):
* short-lived server-side storage for the supporter CSV import wizard so the
* full ok-row set never crosses the Server Action boundary twice.
*
* Hand-trimmed from the Payload-generated diff: the generator also tried to
* recreate the `supporter` table, its enums, FKs, indexes, and the
* `payload_locked_documents_rels.supporter_id` column — all of which already
* exist via `20260718_222656_add_supporter`. Those drift statements were
* removed so this migration is idempotent against databases that already ran
* the supporter migration.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "supporter_import_batch" (
"id" serial PRIMARY KEY NOT NULL,
"batch_id" varchar NOT NULL,
"actor_id" integer NOT NULL,
"expires_at" timestamp(3) with time zone NOT NULL,
"rows" jsonb NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN IF NOT EXISTS "supporter_import_batch_id" integer;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'supporter_import_batch_actor_id_campaign_user_id_fk'
) THEN
ALTER TABLE "supporter_import_batch"
ADD CONSTRAINT "supporter_import_batch_actor_id_campaign_user_id_fk"
FOREIGN KEY ("actor_id") REFERENCES "public"."campaign_user"("id")
ON DELETE set null ON UPDATE no action;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "supporter_import_batch_batch_id_idx"
ON "supporter_import_batch" USING btree ("batch_id");
CREATE INDEX IF NOT EXISTS "supporter_import_batch_actor_idx"
ON "supporter_import_batch" USING btree ("actor_id");
CREATE INDEX IF NOT EXISTS "supporter_import_batch_expires_at_idx"
ON "supporter_import_batch" USING btree ("expires_at");
CREATE INDEX IF NOT EXISTS "supporter_import_batch_updated_at_idx"
ON "supporter_import_batch" USING btree ("updated_at");
CREATE INDEX IF NOT EXISTS "supporter_import_batch_created_at_idx"
ON "supporter_import_batch" USING btree ("created_at");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'payload_locked_documents_rels_supporter_import_batch_fk'
) THEN
ALTER TABLE "payload_locked_documents_rels"
ADD CONSTRAINT "payload_locked_documents_rels_supporter_import_batch_fk"
FOREIGN KEY ("supporter_import_batch_id") REFERENCES "public"."supporter_import_batch"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_supporter_import_batch_id_idx"
ON "payload_locked_documents_rels" USING btree ("supporter_import_batch_id");
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_supporter_import_batch_fk";
DROP INDEX IF EXISTS "payload_locked_documents_rels_supporter_import_batch_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "supporter_import_batch_id";
DROP TABLE IF EXISTS "supporter_import_batch" CASCADE;
`)
}

View file

@ -8,6 +8,7 @@ import * as migration_20260718_190559_territorio_multi_municipio_bairro from './
import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results';
import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter';
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';
export const migrations = [
{
@ -60,4 +61,9 @@ export const migrations = [
down: migration_20260718_222832_add_action_plan.down,
name: '20260718_222832_add_action_plan',
},
{
up: migration_20260719_011015_add_supporter_import_batch.up,
down: migration_20260719_011015_add_supporter_import_batch.down,
name: '20260719_011015_add_supporter_import_batch'
},
];

View file

@ -74,6 +74,7 @@ export interface Config {
electoralNucleus: ElectoralNucleus;
leadership: Leadership;
supporter: Supporter;
supporterImportBatch: SupporterImportBatch;
nucleusUpdate: NucleusUpdate;
actionPlan: ActionPlan;
electionTally: ElectionTally;
@ -101,6 +102,7 @@ export interface Config {
electoralNucleus: ElectoralNucleusSelect<false> | ElectoralNucleusSelect<true>;
leadership: LeadershipSelect<false> | LeadershipSelect<true>;
supporter: SupporterSelect<false> | SupporterSelect<true>;
supporterImportBatch: SupporterImportBatchSelect<false> | SupporterImportBatchSelect<true>;
nucleusUpdate: NucleusUpdateSelect<false> | NucleusUpdateSelect<true>;
actionPlan: ActionPlanSelect<false> | ActionPlanSelect<true>;
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
@ -475,6 +477,27 @@ export interface Supporter {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supporterImportBatch".
*/
export interface SupporterImportBatch {
id: number;
batchId: string;
actor: number | CampaignUser;
expiresAt: string;
rows:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "nucleusUpdate".
@ -909,6 +932,10 @@ export interface PayloadLockedDocument {
relationTo: 'supporter';
value: number | Supporter;
} | null)
| ({
relationTo: 'supporterImportBatch';
value: number | SupporterImportBatch;
} | null)
| ({
relationTo: 'nucleusUpdate';
value: number | NucleusUpdate;
@ -1186,6 +1213,18 @@ export interface SupporterSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supporterImportBatch_select".
*/
export interface SupporterImportBatchSelect<T extends boolean = true> {
batchId?: T;
actor?: T;
expiresAt?: T;
rows?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "nucleusUpdate_select".

View file

@ -24,6 +24,7 @@ import { Post } from './collections/Post'
import { Signature } from './collections/Signature'
import { Subscription } from './collections/Subscription'
import { Supporter } from './collections/Supporter'
import { SupporterImportBatch } from './collections/SupporterImportBatch'
import { Tag } from './collections/Tag'
import { Users } from './collections/Users'
import { HomePage } from './globals/HomePage'
@ -51,6 +52,7 @@ export default buildConfig({
ElectoralNucleus,
Leadership,
Supporter,
SupporterImportBatch,
NucleusUpdate,
ActionPlan,
ElectionTally,

View file

@ -7,6 +7,14 @@ import {
territoryForCity,
} from '@/lib/bahiaTerritories'
import { actionPlanKinds, actionPlanStatuses } from '@/lib/schemas/actionPlan'
import {
buildListHref,
firstValue,
normalizedText,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams as CampaignListRawSearchParams,
} from '@/utilities/campaignListUrl'
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
export const actionPlanPageSize = 25
@ -33,27 +41,12 @@ export type ActionPlanListState = {
city?: string
}
type RawSearchParams = Record<string, string | string[] | undefined>
type RawSearchParams = CampaignListRawSearchParams
export const actionPlanListParamNames = ['tab', 'kind', 'status', 'region', 'city', 'page'] as const
const actionPlanListParamNameSet = new Set<string>(actionPlanListParamNames)
const firstValue = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value
const normalizedText = (value: string | undefined): string | undefined => {
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
const strictDecimalInteger = (value: string | undefined): number | undefined => {
if (!value || !/^[1-9]\d*$/.test(value)) return undefined
const number = Number(value)
return Number.isSafeInteger(number) ? number : undefined
}
const canonicalTerritoryBySearchValue = new Map(
bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]),
)
@ -155,31 +148,8 @@ export const buildActionPlanListSearchParams = (
export const buildActionPlanFiltersKey = (state: ActionPlanListState): string =>
buildActionPlanListSearchParams(state).toString()
export const buildActionPlanListHref = (state: ActionPlanListState, page: number): string => {
const params = buildActionPlanListSearchParams(state, page)
const query = params.toString()
return query ? `/campanha/planos?${query}` : '/campanha/planos'
}
const inspectRawActionPlanListParams = (
params: RawSearchParams,
): { hasUnsupportedParams: boolean; query: string } => {
const serialized = new URLSearchParams()
let hasUnsupportedParams = false
for (const [name, value] of Object.entries(params)) {
if (!actionPlanListParamNameSet.has(name)) {
hasUnsupportedParams = true
continue
}
if (value === undefined) continue
for (const item of Array.isArray(value) ? value : [value]) {
serialized.append(name, item)
}
}
return { hasUnsupportedParams, query: serialized.toString() }
}
export const buildActionPlanListHref = (state: ActionPlanListState, page: number): string =>
buildListHref(state, buildActionPlanListSearchParams, '/campanha/planos', page)
export const resolveActionPlanListUrl = (
params: RawSearchParams,
@ -188,22 +158,12 @@ export const resolveActionPlanListUrl = (
state: ActionPlanListState
href: string
redirectHref?: string
} => {
const parsedState = parseActionPlanListParams(params)
const page =
totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages
? totalPages
: parsedState.page
const state = page === parsedState.page ? parsedState : { ...parsedState, page }
const canonicalParams = buildActionPlanListSearchParams(state)
const canonicalQuery = canonicalParams.toString()
const href = canonicalQuery ? `/campanha/planos?${canonicalQuery}` : '/campanha/planos'
const raw = inspectRawActionPlanListParams(params)
const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery
return {
state,
href,
...(needsRedirect ? { redirectHref: href } : {}),
}
}
} =>
resolveListUrl({
params,
paramNameSet: actionPlanListParamNameSet,
parse: parseActionPlanListParams,
buildSearchParams: buildActionPlanListSearchParams,
basePath: '/campanha/planos',
totalPages,
})

View file

@ -0,0 +1,37 @@
import { ZodError } from 'zod'
import { FormDataBoundaryError, validationFieldErrors } from '@/lib/formData'
export type CampaignFormErrorState<Values> = {
fieldErrors?: Record<string, string[]>
message?: string
values?: Values
revision?: number
}
export type MapCampaignFormActionErrorOptions<Values> = {
error: unknown
safeMessages: readonly string[]
genericMessage: string
values?: Values
revision?: number
resolveBoundaryMessage?: (error: FormDataBoundaryError) => string
}
export const mapCampaignFormActionError = <Values>(
options: MapCampaignFormActionErrorOptions<Values>,
): CampaignFormErrorState<Values> => {
const { error, safeMessages, genericMessage, values, revision, resolveBoundaryMessage } = options
if (error instanceof FormDataBoundaryError) {
const message = resolveBoundaryMessage ? resolveBoundaryMessage(error) : error.message
return { fieldErrors: { [error.field]: [message] }, values, revision }
}
if (error instanceof ZodError) {
return { fieldErrors: validationFieldErrors(error), values, revision }
}
if (error instanceof Error && safeMessages.includes(error.message)) {
return { message: error.message, values, revision }
}
return { message: genericMessage, values, revision }
}

View file

@ -0,0 +1,21 @@
export const fieldError = (
fieldErrors: Record<string, string[]> | undefined,
field: string,
): string | undefined => fieldErrors?.[field]?.[0]
export const errorProps = (
fieldErrors: Record<string, string[]> | undefined,
field: string,
idPrefix: string,
): {
error: string | undefined
invalid: boolean
describedBy: string | undefined
} => {
const error = fieldError(fieldErrors, field)
return {
error,
invalid: Boolean(error),
describedBy: error ? `${idPrefix}-${field}-error` : undefined,
}
}

View file

@ -0,0 +1,86 @@
export type RawSearchParams = Record<string, string | string[] | undefined>
export const firstValue = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value
export const normalizedText = (value: string | undefined): string | undefined => {
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
export const strictDecimalInteger = (value: string | undefined): number | undefined => {
if (!value || !/^[1-9]\d*$/.test(value)) return undefined
const number = Number(value)
return Number.isSafeInteger(number) ? number : undefined
}
export const inspectRawListParams = (
params: RawSearchParams,
paramNameSet: ReadonlySet<string>,
): { hasUnsupportedParams: boolean; query: string } => {
const serialized = new URLSearchParams()
let hasUnsupportedParams = false
for (const [name, value] of Object.entries(params)) {
if (!paramNameSet.has(name)) {
hasUnsupportedParams = true
continue
}
if (value === undefined) continue
for (const item of Array.isArray(value) ? value : [value]) {
serialized.append(name, item)
}
}
return { hasUnsupportedParams, query: serialized.toString() }
}
export type ListStateWithPage = { page: number }
export type ResolveListUrlOptions<State extends ListStateWithPage> = {
params: RawSearchParams
paramNameSet: ReadonlySet<string>
parse: (params: RawSearchParams) => State
buildSearchParams: (state: State) => URLSearchParams
basePath: string
totalPages?: number
}
export const resolveListUrl = <State extends ListStateWithPage>(
options: ResolveListUrlOptions<State>,
): {
state: State
href: string
redirectHref?: string
} => {
const { params, paramNameSet, parse, buildSearchParams, basePath, totalPages } = options
const parsedState = parse(params)
const page =
totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages
? totalPages
: parsedState.page
const state = page === parsedState.page ? parsedState : ({ ...parsedState, page } as State)
const canonicalParams = buildSearchParams(state)
const canonicalQuery = canonicalParams.toString()
const href = canonicalQuery ? `${basePath}?${canonicalQuery}` : basePath
const raw = inspectRawListParams(params, paramNameSet)
const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery
return {
state,
href,
...(needsRedirect ? { redirectHref: href } : {}),
}
}
export const buildListHref = <State>(
state: State,
buildSearchParams: (state: State, page?: number) => URLSearchParams,
basePath: string,
page: number,
): string => {
const params = buildSearchParams(state, page)
const query = params.toString()
return query ? `${basePath}?${query}` : basePath
}

View file

@ -7,6 +7,14 @@ import {
territoryForCity,
} from '@/lib/bahiaTerritories'
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
import {
buildListHref,
firstValue,
normalizedText,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams as CampaignListRawSearchParams,
} from '@/utilities/campaignListUrl'
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
export const nucleusPageSize = 25
@ -63,7 +71,7 @@ export type NucleusListState = {
estimate?: 'confirmada' | 'sem_confirmacao'
}
type RawSearchParams = Record<string, string | string[] | undefined>
type RawSearchParams = CampaignListRawSearchParams
export const nucleusListParamNames = [
'q',
@ -77,21 +85,6 @@ export const nucleusListParamNames = [
const nucleusListParamNameSet = new Set<string>(nucleusListParamNames)
const firstValue = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value
const normalizedText = (value: FormDataEntryValue | string | undefined): string | undefined => {
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
const strictDecimalInteger = (value: string | undefined): number | undefined => {
if (!value || !/^[1-9]\d*$/.test(value)) return undefined
const number = Number(value)
return Number.isSafeInteger(number) ? number : undefined
}
const canonicalTerritoryBySearchValue = new Map(
bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]),
)
@ -198,31 +191,8 @@ export const buildNucleusListSearchParams = (
export const buildNucleusFiltersKey = (state: NucleusListState): string =>
buildNucleusListSearchParams(state).toString()
export const buildNucleusListHref = (state: NucleusListState, page: number): string => {
const params = buildNucleusListSearchParams(state, page)
const query = params.toString()
return query ? `/campanha/nucleos?${query}` : '/campanha/nucleos'
}
const inspectRawNucleusListParams = (
params: RawSearchParams,
): { hasUnsupportedParams: boolean; query: string } => {
const serialized = new URLSearchParams()
let hasUnsupportedParams = false
for (const [name, value] of Object.entries(params)) {
if (!nucleusListParamNameSet.has(name)) {
hasUnsupportedParams = true
continue
}
if (value === undefined) continue
for (const item of Array.isArray(value) ? value : [value]) {
serialized.append(name, item)
}
}
return { hasUnsupportedParams, query: serialized.toString() }
}
export const buildNucleusListHref = (state: NucleusListState, page: number): string =>
buildListHref(state, buildNucleusListSearchParams, '/campanha/nucleos', page)
export const resolveNucleusListUrl = (
params: RawSearchParams,
@ -231,25 +201,15 @@ export const resolveNucleusListUrl = (
state: NucleusListState
href: string
redirectHref?: string
} => {
const parsedState = parseNucleusListParams(params)
const page =
totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages
? totalPages
: parsedState.page
const state = page === parsedState.page ? parsedState : { ...parsedState, page }
const canonicalParams = buildNucleusListSearchParams(state)
const canonicalQuery = canonicalParams.toString()
const href = canonicalQuery ? `/campanha/nucleos?${canonicalQuery}` : '/campanha/nucleos'
const raw = inspectRawNucleusListParams(params)
const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery
return {
state,
href,
...(needsRedirect ? { redirectHref: href } : {}),
}
}
} =>
resolveListUrl({
params,
paramNameSet: nucleusListParamNameSet,
parse: parseNucleusListParams,
buildSearchParams: buildNucleusListSearchParams,
basePath: '/campanha/nucleos',
totalPages,
})
export const getCampaignScopeLabel = (role: CampaignUser['role'], nucleusCount: number): string => {
if (role === 'coordenador') {

View file

@ -33,14 +33,22 @@ export type SupporterImportPreviewRow =
})
export type SupporterImportPreviewResult = {
rows: SupporterImportPreviewRow[]
counts: {
ok: number
duplicate: number
error: number
}
/** First N rows for the on-screen preview table (the full set stays server-side). */
sampleRows: SupporterImportPreviewRow[]
/** Precomputed CSV of every error row, for the "Baixar relatório de erros" button. */
errorReportCsv: string
/** HMAC-signed token that `confirmSupporterImport` exchanges for the staged batch. */
importToken: string
}
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'

View file

@ -0,0 +1,249 @@
import 'server-only'
import type { Payload } from 'payload'
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
nome: string
municipio?: string
intencao?: SupporterVoteIntention
}
export type SupporterImportBulkResult = {
created: number
skipped: number
errors: Array<{ telefone: string; message: string }>
}
type DrizzleInsertResult = { rowCount?: number | null }
type DrizzleTx = {
insert: (table: unknown) => {
values: (rows: Record<string, unknown>[]) => {
onConflictDoNothing: () => Promise<DrizzleInsertResult>
}
}
}
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
}
const buildContactRows = (
rows: SupporterImportBulkRow[],
now: string,
): Array<Record<string, unknown>> =>
rows.map((row) => ({
name: row.nome,
phone: row.telefone,
email: null,
state: 'BA',
city: row.municipio ?? null,
postalCode: null,
gender: null,
createdAt: now,
updatedAt: now,
}))
const buildSupporterRows = (
entries: Array<{
row: SupporterImportBulkRow
contactID: number
}>,
args: {
actorID: number
registrationConsent: ConsentDescriptor
voteIntentionConsent: ConsentDescriptor
consentNote: string
now: string
},
): Array<Record<string, unknown>> =>
entries.map(({ row, contactID }) => ({
// Drizzle column names for Payload relationship fields omit the `Id` suffix.
contact: contactID,
nucleus: null,
voteIntention: row.intencao ?? null,
consent: args.registrationConsent.id,
consentContentHash: args.registrationConsent.contentHash,
consentedAt: args.now,
voteIntentionConsent: row.intencao ? args.voteIntentionConsent.id : null,
voteIntentionConsentContentHash: row.intencao
? args.voteIntentionConsent.contentHash
: null,
voteIntentionConsentedAt: row.intencao ? args.now : null,
source: 'import_csv',
consentNote: args.consentNote,
notes: null,
createdBy: args.actorID,
createdAt: args.now,
updatedAt: args.now,
}))
/**
* 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
* every phone in `rows` (the caller acquires them before invoking this helper).
*
* Contacts are inserted via drizzle (bypassing the Contact phone-invariant hook,
* 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.
*/
export const bulkInsertSupporterImport = async (args: {
payload: Payload
req: PayloadTransactionRequest
actorID: number
rows: SupporterImportBulkRow[]
registrationConsent: ConsentDescriptor
voteIntentionConsent: ConsentDescriptor
consentNote: string
}): Promise<SupporterImportBulkResult> => {
const { payload, req, actorID, rows } = args
if (rows.length === 0) {
return { created: 0, skipped: 0, errors: [] }
}
const tables = getTables(payload)
const contactTable = requireTable(tables, CONTACT_TABLE)
const supporterTable = requireTable(tables, SUPPORTER_TABLE)
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),
)
// New contacts to create, deduped by phone (preview already dedupes, but this
// is defensive against a stale confirm payload).
const newContactByPhone = new Map<string, SupporterImportBulkRow>()
for (const row of rows) {
if (contactIdByPhone.has(row.telefone)) continue
if (newContactByPhone.has(row.telefone)) continue
newContactByPhone.set(row.telefone, row)
}
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 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 candidateEntries: Array<{ row: SupporterImportBulkRow; contactID: number }> = []
const errors: Array<{ telefone: string; message: string }> = []
let skipped = 0
for (const row of rows) {
const contactID = contactIdByPhone.get(row.telefone)
if (!contactID) {
skipped += 1
errors.push({ telefone: row.telefone, message: 'Contato não encontrado após upsert.' })
continue
}
if (supporterContactIDs.has(contactID)) {
skipped += 1
continue
}
candidateEntries.push({ row, contactID })
// Reserve against duplicate phones within the same payload.
supporterContactIDs.add(contactID)
}
let created = 0
for (const batch of chunk(candidateEntries, INSERT_CHUNK_SIZE)) {
const result = await tx
.insert(supporterTable)
.values(
buildSupporterRows(batch, {
actorID,
registrationConsent: args.registrationConsent,
voteIntentionConsent: args.voteIntentionConsent,
consentNote: args.consentNote,
now,
}),
)
.onConflictDoNothing()
// 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 }
}

View file

@ -0,0 +1,151 @@
import 'server-only'
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
import type { Payload } from 'payload'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
export type SupporterImportOkRow = {
nome: string
telefone: string
municipio?: string
intencao?: SupporterVoteIntention
}
export type StagedSupporterImportBatch = {
actorID: number
expiresAt: number
okRows: SupporterImportOkRow[]
}
const BATCH_COLLECTION = 'supporterImportBatch'
/** Short-lived server-side staging; the wizard must confirm within this window. */
const IMPORT_TOKEN_TTL_MS = 10 * 60 * 1000
export const storeSupporterImportBatch = async (
payload: Payload,
batchId: string,
batch: StagedSupporterImportBatch,
req?: PayloadTransactionRequest,
): Promise<void> => {
await payload.create({
collection: BATCH_COLLECTION,
data: {
batchId,
actor: batch.actorID,
expiresAt: new Date(batch.expiresAt).toISOString(),
rows: batch.okRows,
},
depth: 0,
overrideAccess: true,
req,
})
}
export const loadSupporterImportBatch = async (
payload: Payload,
batchId: string,
expectedActorID: number,
req?: PayloadTransactionRequest,
): Promise<StagedSupporterImportBatch> => {
const result = await payload.find({
collection: BATCH_COLLECTION,
where: { batchId: { equals: batchId } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
})
const doc = result.docs[0] as
| { id: number; actor: number; expiresAt: string; rows: unknown }
| undefined
if (!doc) throw new Error('Lote de importação não encontrado ou expirado.')
if (doc.actor !== expectedActorID) {
throw new Error('Lote de importação inválido.')
}
const expiresAtMs = Date.parse(doc.expiresAt)
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
throw new Error('Lote de importação expirado.')
}
if (!Array.isArray(doc.rows)) {
throw new Error('Lote de importação inválido.')
}
return {
actorID: expectedActorID,
expiresAt: expiresAtMs,
okRows: doc.rows as SupporterImportOkRow[],
}
}
export const deleteSupporterImportBatch = async (
payload: Payload,
batchId: string,
req?: PayloadTransactionRequest,
): Promise<void> => {
const result = await payload.find({
collection: BATCH_COLLECTION,
where: { batchId: { equals: batchId } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
})
const doc = result.docs[0] as { id: number } | undefined
if (!doc) return
await payload.delete({
collection: BATCH_COLLECTION,
id: doc.id,
depth: 0,
overrideAccess: true,
req,
})
}
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.')
return secret
}
const signature = (batchId: string, actorID: number, expiresAt: number): Buffer =>
createHmac('sha256', getSecret()).update(`${batchId}.${actorID}.${expiresAt}`).digest()
export type SignedImportToken = {
token: string
batchId: string
expiresAt: number
}
export const issueSupporterImportToken = (actorID: number): SignedImportToken => {
const batchId = randomUUID()
const expiresAt = Date.now() + IMPORT_TOKEN_TTL_MS
const sig = signature(batchId, actorID, expiresAt).toString('base64url')
return { token: `${batchId}.${expiresAt}.${sig}`, batchId, expiresAt }
}
export const verifySupporterImportToken = (
token: string,
actorID: number,
): { batchId: string; expiresAt: number } => {
const parts = token.split('.')
if (parts.length !== 3) throw new Error('Token de importação inválido.')
const [batchId, expiresAtText, sig] = parts as [string, string, string]
const expiresAt = Number(expiresAtText)
if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) {
throw new Error('Token de importação expirado.')
}
const expected = signature(batchId, actorID, expiresAt)
const provided = Buffer.from(sig, 'base64url')
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
throw new Error('Token de importação inválido.')
}
return { batchId, expiresAt }
}

View file

@ -0,0 +1,147 @@
import 'server-only'
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
import type { CampaignUser } from '@/payload-types'
import { isCampaignGeneral } from '@/utilities/campaignAccess'
import { normalizeBrazilianPhone } from '@/utilities/phone'
import type { SupporterListState } from '@/utilities/supporterUi'
import type { SupporterListOverviewViewModel } from '@/utilities/supporterViewModels'
type AccessConstraint =
| { kind: 'all' }
| { kind: 'none' }
| { kind: 'nucleusSet'; ids: number[] }
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,
): 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)
return { kind: 'nucleusSet', ids }
}
const buildAggregateSql = (
state: SupporterListState,
access: AccessConstraint,
): ReturnType<typeof sql> => {
const conditions: ReturnType<typeof sql>[] = []
const needsContactJoin = Boolean(state.q || state.city)
if (access.kind === 'nucleusSet') {
if (access.ids.length === 0) {
// No accessible nuclei → no rows. Keep a trivially-false condition.
conditions.push(sql`FALSE`)
} else {
conditions.push(
sql`"supporter"."nucleus_id" IN (${sql.join(
access.ids.map((id) => sql`${id}`),
sql`, `,
)})`,
)
}
}
if (state.voteIntention) {
conditions.push(sql`"supporter"."vote_intention" = ${state.voteIntention}`)
}
if (state.nucleus) {
conditions.push(sql`"supporter"."nucleus_id" = ${state.nucleus}`)
}
if (state.city) {
conditions.push(sql`"contact"."city" = ${state.city}`)
}
if (state.q) {
const searchTerms: ReturnType<typeof sql>[] = [
sql`"contact"."name" ILIKE ${`%${state.q}%`}`,
sql`"contact"."city" ILIKE ${`%${state.q}%`}`,
]
const normalizedPhone = normalizeBrazilianPhone(state.q)
if (normalizedPhone) {
searchTerms.push(sql`"contact"."phone" = ${normalizedPhone}`)
} else if (/\d/.test(state.q)) {
const digits = state.q.replace(/\D/g, '')
if (digits) searchTerms.push(sql`"contact"."phone" ILIKE ${`%${digits}%`}`)
}
conditions.push(sql`(${sql.join(searchTerms, sql` OR `)})`)
}
const whereClause =
conditions.length > 0 ? sql` WHERE ${sql.join(conditions, sql` AND `)}` : sql``
const fromClause = needsContactJoin
? sql`FROM "supporter" LEFT JOIN "contact" ON "contact"."id" = "supporter"."contact_id"`
: sql`FROM "supporter"`
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}
`
}
export const computeSupporterListOverviewAggregate = async (
payload: Payload,
user: CampaignUser,
state: SupporterListState,
): Promise<SupporterListOverviewViewModel | null> => {
const access = await resolveAccessConstraint(payload, user)
if (access.kind === 'none') return null
if (payload.db.name !== 'postgres') {
throw new Error('O overview de apoiadores exige o adaptador PostgreSQL.')
}
const database = payload.db as unknown as { drizzle?: PostgresDb }
const drizzle = database.drizzle
if (!drizzle || typeof drizzle.execute !== 'function') {
throw new Error('A sessão PostgreSQL do overview de apoiadores não está disponível.')
}
const result = await drizzle.execute(buildAggregateSql(state, access))
const row = resultRows(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),
indeciso: Number(row.indeciso ?? 0),
}
}

View file

@ -5,6 +5,7 @@ import {
getSupporterRegistrationConsent,
getSupporterVoteIntentionConsent,
} from '@/utilities/campaignConsent'
import { computeSupporterListOverviewAggregate } from '@/utilities/supporterListOverviewAggregate'
import {
buildSupporterListWhere,
resolveSupporterListUrl,
@ -102,45 +103,11 @@ export const loadSupporterListPageData = async (
}
export const loadSupporterListOverviewData = async (
payload: Pick<Payload, 'count'>,
payload: Payload,
user: CampaignUser,
state: SupporterListState,
): Promise<SupporterListOverviewViewModel | null> => {
const where = buildSupporterListWhere(state)
const [total, certoAndTende, indeciso] = await Promise.all([
payload.count({
collection: 'supporter',
where,
user,
overrideAccess: false,
}),
payload.count({
collection: 'supporter',
where: {
and: [where, { voteIntention: { in: ['certo', 'tende_a_certo'] } }],
},
user,
overrideAccess: false,
}),
payload.count({
collection: 'supporter',
where: {
and: [where, { voteIntention: { equals: 'indeciso' } }],
},
user,
overrideAccess: false,
}),
])
if (total.totalDocs === 0) return null
return {
total: total.totalDocs,
certoAndTende: certoAndTende.totalDocs,
indeciso: indeciso.totalDocs,
}
}
): Promise<SupporterListOverviewViewModel | null> =>
computeSupporterListOverviewAggregate(payload, user, state)
export const loadSupporterDetailPageData = async (
payload: Pick<Payload, 'find'>,

View file

@ -6,6 +6,14 @@ import {
type SupporterVoteIntention,
} from '@/lib/schemas/supporter'
import type { CampaignUser, Supporter } from '@/payload-types'
import {
buildListHref,
firstValue,
normalizedText,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams as CampaignListRawSearchParams,
} from '@/utilities/campaignListUrl'
import { normalizeBrazilianPhone } from '@/utilities/phone'
export const supporterPageSize = 25
@ -32,27 +40,12 @@ export type SupporterListState = {
nucleus?: number
}
type RawSearchParams = Record<string, string | string[] | undefined>
type RawSearchParams = CampaignListRawSearchParams
export const supporterListParamNames = ['q', 'voteIntention', 'city', 'nucleus', 'page'] as const
const supporterListParamNameSet = new Set<string>(supporterListParamNames)
const firstValue = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value
const normalizedText = (value: FormDataEntryValue | string | undefined): string | undefined => {
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
const strictDecimalInteger = (value: string | undefined): number | undefined => {
if (!value || !/^[1-9]\d*$/.test(value)) return undefined
const number = Number(value)
return Number.isSafeInteger(number) ? number : undefined
}
export const parseSupporterListParams = (params: RawSearchParams): SupporterListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const q = normalizedText(firstValue(params.q))
@ -125,31 +118,8 @@ export const buildSupporterListSearchParams = (
export const buildSupporterFiltersKey = (state: SupporterListState): string =>
buildSupporterListSearchParams(state).toString()
export const buildSupporterListHref = (state: SupporterListState, page: number): string => {
const params = buildSupporterListSearchParams(state, page)
const query = params.toString()
return query ? `/campanha/apoiadores?${query}` : '/campanha/apoiadores'
}
const inspectRawSupporterListParams = (
params: RawSearchParams,
): { hasUnsupportedParams: boolean; query: string } => {
const serialized = new URLSearchParams()
let hasUnsupportedParams = false
for (const [name, value] of Object.entries(params)) {
if (!supporterListParamNameSet.has(name)) {
hasUnsupportedParams = true
continue
}
if (value === undefined) continue
for (const item of Array.isArray(value) ? value : [value]) {
serialized.append(name, item)
}
}
return { hasUnsupportedParams, query: serialized.toString() }
}
export const buildSupporterListHref = (state: SupporterListState, page: number): string =>
buildListHref(state, buildSupporterListSearchParams, '/campanha/apoiadores', page)
export const resolveSupporterListUrl = (
params: RawSearchParams,
@ -158,25 +128,15 @@ export const resolveSupporterListUrl = (
state: SupporterListState
href: string
redirectHref?: string
} => {
const parsedState = parseSupporterListParams(params)
const page =
totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages
? totalPages
: parsedState.page
const state = page === parsedState.page ? parsedState : { ...parsedState, page }
const canonicalParams = buildSupporterListSearchParams(state)
const canonicalQuery = canonicalParams.toString()
const href = canonicalQuery ? `/campanha/apoiadores?${canonicalQuery}` : '/campanha/apoiadores'
const raw = inspectRawSupporterListParams(params)
const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery
return {
state,
href,
...(needsRedirect ? { redirectHref: href } : {}),
}
}
} =>
resolveListUrl({
params,
paramNameSet: supporterListParamNameSet,
parse: parseSupporterListParams,
buildSearchParams: buildSupporterListSearchParams,
basePath: '/campanha/apoiadores',
totalPages,
})
export const getSupporterScopeLabel = (total: number): string =>
`${total} ${total === 1 ? 'apoiador nos seus núcleos' : 'apoiadores nos seus núcleos'}`

View file

@ -0,0 +1,77 @@
/* eslint-disable check-file/filename-naming-convention */
import { test, expect } from './fixtures/campaignE2EFixtures'
test.describe('Campaign supporter journeys', () => {
test.describe.configure({ mode: 'parallel' })
test('removes a supporter through the confirmation dialog', async ({ campaign, page }) => {
test.setTimeout(240_000)
const generalEmail = `${campaign.fixtures.value('removal-general')}@example.com`
const password = campaign.fixtures.value('RemovalPassword')
const supporterName = campaign.fixtures.value('Apoiador removível')
const setup = await campaign.transaction(async (payload, req) => {
const general = await payload.create({
collection: 'campaignUser',
data: {
name: campaign.fixtures.value('Coordenação Geral remoção'),
email: generalEmail,
password,
role: 'geral',
},
depth: 0,
req,
})
const contact = await payload.create({
collection: 'contact',
data: {
name: supporterName,
phone: campaign.fixtures.phone(),
state: 'BA',
city: 'Salvador',
},
depth: 0,
req,
})
const supporter = await payload.create({
collection: 'supporter',
data: {
contact: contact.id,
source: 'manual',
createdBy: general.id,
},
depth: 0,
req,
})
return { general, supporter }
})
await campaign.login(page, generalEmail, password)
await page.goto(`${campaign.baseURL}/campanha/apoiadores/${setup.supporter.id}`)
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Remover dados' }).click()
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 20_000 })
await expect(
page.getByRole('heading', { name: 'Remover os dados deste apoiador?' }),
).toBeVisible()
await page.getByRole('button', { name: 'Confirmar remoção' }).click()
await page.waitForURL(`${campaign.baseURL}/campanha/apoiadores`, { timeout: 30_000 })
await expect
.poll(
async () =>
(
await campaign.payload.find({
collection: 'supporter',
where: { id: { equals: setup.supporter.id } },
depth: 0,
limit: 1,
})
).totalDocs,
{ timeout: 30_000 },
)
.toBe(0)
})
})

View file

@ -21,11 +21,13 @@ type OwnedCollection =
| 'nucleusUpdate'
| 'campaignInvite'
| 'consent'
| 'supporter'
const deletionOrder: OwnedCollection[] = [
'campaignInvite',
'nucleusUpdate',
'leadership',
'supporter',
'electoralNucleus',
'contact',
'campaignUser',
@ -174,6 +176,21 @@ class CampaignE2EOwnership {
})
for (const update of updates.docs) this.own('nucleusUpdate', update.id)
}
if (contactIDs.length || nucleusIDs.length || userIDs.length) {
const supporters = await this.rootPayload.find({
collection: 'supporter',
where: {
or: [
...(contactIDs.length ? [{ contact: { in: contactIDs } }] : []),
...(nucleusIDs.length ? [{ nucleus: { in: nucleusIDs } }] : []),
...(userIDs.length ? [{ createdBy: { in: userIDs } }] : []),
],
},
depth: 0,
pagination: false,
})
for (const supporter of supporters.docs) this.own('supporter', supporter.id)
}
}
async cleanup(): Promise<void> {

View file

@ -16,6 +16,7 @@ import {
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_VOTE_INTENTION_CONSENT_KEY,
} from '@/utilities/campaignConsent'
import { loadSupporterListOverviewData } from '@/utilities/supporterPageData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
@ -251,22 +252,23 @@ describe('campaign supporter domain', () => {
expect(preview.counts.ok).toBe(1)
expect(preview.counts.duplicate).toBe(1)
expect(preview.counts.error).toBeGreaterThanOrEqual(2)
const okRows = preview.rows
.filter((row) => row.status === 'ok')
.map((row) => ({
nome: row.nome,
telefone: row.normalizedPhone!,
municipio: row.canonicalCity,
intencao: row.voteIntention,
}))
expect(preview.importToken).toBeTruthy()
expect(preview.sampleRows.length).toBeGreaterThan(0)
const result = await confirmSupporterImportRecord(payload, geral, {
operatorAttested: true,
rows: okRows,
importToken: preview.importToken,
})
expect(result.created).toBe(1)
// Single-use token: a second confirm with the same token must fail.
await expect(
confirmSupporterImportRecord(payload, geral, {
operatorAttested: true,
importToken: preview.importToken,
}),
).rejects.toThrow(/não encontrado|expirado|inválido/)
const contact = await payload.find({
collection: 'contact',
where: { phone: { equals: phoneOk } },
@ -288,6 +290,57 @@ describe('campaign supporter domain', () => {
fixtures.own('supporter', created.docs[0]!.id)
})
it('rejects a tampered import token and a token issued to another actor', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents(fixtures)
const geral = await fixtures.createCampaignUser('geral')
const otherGeral = await fixtures.createCampaignUser('geral')
const phoneOk = fixtures.phone()
const csv = `nome,telefone,municipio,intencao\nAna Silva,${phoneOk},Salvador,certo`
const preview = await previewSupporterImportText(payload, geral, csv)
expect(preview.counts.ok).toBe(1)
const [batchId, expiresAt, sig] = preview.importToken.split('.')
const tampered = `${batchId}.${expiresAt}.${sig.slice(0, -2)}xx`
await expect(
confirmSupporterImportRecord(payload, geral, {
operatorAttested: true,
importToken: tampered,
}),
).rejects.toThrow(/inválido|expirado/)
// Token bound to a different actor cannot be redeemed by `geral`.
await expect(
confirmSupporterImportRecord(payload, otherGeral, {
operatorAttested: true,
importToken: preview.importToken,
}),
).rejects.toThrow(/não encontrado|inválido|expirado/)
// Cleanup: consume the still-valid token so it does not leak into other tests.
await confirmSupporterImportRecord(payload, geral, {
operatorAttested: true,
importToken: preview.importToken,
})
const contact = await payload.find({
collection: 'contact',
where: { phone: { equals: phoneOk } },
depth: 0,
limit: 1,
overrideAccess: true,
})
fixtures.own('contact', contact.docs[0]!.id)
const created = await payload.find({
collection: 'supporter',
where: { contact: { equals: contact.docs[0]!.id } },
depth: 0,
limit: 1,
overrideAccess: true,
})
if (created.docs[0]) fixtures.own('supporter', created.docs[0].id)
})
it('removes supporter and anonymizes contact when no other joins remain', async () => {
const fixtures = campaignFixtures()
await ensureSupporterConsents(fixtures)
@ -315,4 +368,81 @@ describe('campaign supporter domain', () => {
expect(contact.name).toBe('Titular removido')
expect(contact.phone).not.toBe(phone)
})
it('aggregates supporter overview KPIs in a single SQL query for geral', async () => {
const fixtures = campaignFixtures()
const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
const geral = await fixtures.createCampaignUser('geral')
const intentions: Array<{ voteIntention: 'certo' | 'tende_a_certo' | 'indeciso' | 'outro' | null }> = [
{ voteIntention: 'certo' },
{ voteIntention: 'certo' },
{ voteIntention: 'tende_a_certo' },
{ voteIntention: 'indeciso' },
{ voteIntention: 'indeciso' },
{ voteIntention: 'outro' },
{ voteIntention: null },
]
for (const { voteIntention } of intentions) {
const contact = await fixtures.createContact()
await fixtures.createSupporter({
contact: contact.id,
consent: registration.id,
consentContentHash: 'hash',
consentedAt: new Date().toISOString(),
createdBy: geral.id,
...(voteIntention ? { voteIntention } : {}),
})
}
const overview = await loadSupporterListOverviewData(payload, geral, { page: 1 })
expect(overview).not.toBeNull()
expect(overview!.total).toBe(intentions.length)
expect(overview!.certoAndTende).toBe(3)
expect(overview!.indeciso).toBe(2)
})
it('scopes the overview aggregate to accessible nuclei for coordenador and applies filters', async () => {
const fixtures = campaignFixtures()
const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY)
const coordenador = await fixtures.createCampaignUser('coordenador')
const assigned = await fixtures.createNucleus({ coordinators: [coordenador.id] })
const other = await fixtures.createNucleus()
const makeSupporter = async (
nucleusId: number | undefined,
voteIntention: 'certo' | 'indeciso' | null,
city = 'Salvador',
name = fixtures.value('Apoiador'),
) => {
const contact = await fixtures.createContact({ city, name })
await fixtures.createSupporter({
contact: contact.id,
consent: registration.id,
consentContentHash: 'hash',
consentedAt: new Date().toISOString(),
createdBy: coordenador.id,
...(nucleusId ? { nucleus: nucleusId } : {}),
...(voteIntention ? { voteIntention } : {}),
})
}
await makeSupporter(assigned.id, 'certo')
await makeSupporter(assigned.id, 'indeciso')
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 })
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',
})
expect(filtered).not.toBeNull()
expect(filtered!.total).toBe(1)
})
})

View file

@ -5,9 +5,9 @@ import { afterEach, describe, expect, it } from 'vitest'
import { archiveNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions'
import { ArchiveNucleusDialog } from '@/components/campaign/ArchiveNucleusDialog'
import { getPaginationPages } from '@/components/campaign/CampaignListPagination'
import { NucleusFormFields } from '@/components/campaign/NucleusForm'
import { NucleusList } from '@/components/campaign/NucleusList'
import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination'
import { NucleusTerritoryAndZonesFields } from '@/components/campaign/NucleusTerritoryAndZonesFields'
import { TseZoneInput } from '@/components/campaign/TseZoneInput'
import { campaignNav, getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav'
@ -498,9 +498,9 @@ describe('campaign nucleus UI contracts', () => {
})
it('keeps pagination boundaries compact and valid', () => {
expect(getNucleusPaginationPages(1, 10)).toEqual([1, 2, 10])
expect(getNucleusPaginationPages(10, 10)).toEqual([1, 9, 10])
expect(getNucleusPaginationPages(5, 10)).toEqual([1, 4, 5, 6, 10])
expect(getPaginationPages(1, 10)).toEqual([1, 2, 10])
expect(getPaginationPages(10, 10)).toEqual([1, 9, 10])
expect(getPaginationPages(5, 10)).toEqual([1, 4, 5, 6, 10])
})
it('returns a recoverable archive error for an invalid identifier', async () => {

View file

@ -45,19 +45,25 @@ describe('supporter schemas', () => {
).toBe(true)
})
it('requires operator attestation for import confirm', () => {
it('requires operator attestation and a token for import confirm', () => {
expect(
supporterImportConfirmSchema.safeParse({
rows: [{ nome: 'Ana Silva', telefone: '71988887777' }],
importToken: 'token',
}).success,
).toBe(false)
expect(
supporterImportConfirmSchema.safeParse({
operatorAttested: true,
}).success,
).toBe(false)
expect(
supporterImportConfirmSchema.parse({
operatorAttested: true,
rows: [{ nome: 'Ana Silva', telefone: '71988887777', intencao: 'certo' }],
}).rows[0]?.telefone,
).toBe('71988887777')
importToken: 'batch.123.sig',
}).importToken,
).toBe('batch.123.sig')
})
it('requires vote intention consent acceptance', () => {