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