From 8420969b4b03dab48baab7656b6108d3a20b72d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 19 Jul 2026 16:56:10 -0300 Subject: [PATCH] Deliver A7 Fase 1 federal ranking aggregate and register simplify follow-ups. Replace full Local API federal T1 loads with drizzle GROUP BY on nucleus detail, gate election reads via assertCanReadElectionData, and absorb post-simplify cleanup into the A7 plan and roadmap. --- docs/plans/escala-dry-pos-a4.md | 32 +++---- docs/roadmap.md | 8 +- src/utilities/campaignAccess.ts | 14 +++- src/utilities/drizzleBulk.ts | 20 +++++ .../federalCandidateTotalsAggregate.ts | 60 +++++++++++++ src/utilities/nucleusElectoralBaseline.ts | 67 ++++++++------- .../int/nucleusElectoralBaseline.int.spec.ts | 67 ++++++++++++++- ...deralCandidateTotalsAggregate.unit.spec.ts | 84 +++++++++++++++++++ .../nucleusElectoralBaseline.unit.spec.ts | 41 +++++++-- 9 files changed, 332 insertions(+), 61 deletions(-) create mode 100644 src/utilities/federalCandidateTotalsAggregate.ts create mode 100644 tests/unit/federalCandidateTotalsAggregate.unit.spec.ts diff --git a/docs/plans/escala-dry-pos-a4.md b/docs/plans/escala-dry-pos-a4.md index 1eede402..019698d2 100644 --- a/docs/plans/escala-dry-pos-a4.md +++ b/docs/plans/escala-dry-pos-a4.md @@ -1,7 +1,7 @@ # Escala e DRY pós-A4 (baseline TSE no produto) -Status: registrado no roadmap (fases pendentes) -Atualizado em: 2026-07-19 (nota A5-1 em Fase 3 / `capture-review-debts`) +Status: Fase 1 implementada (Fases 2–3 pendentes; merge pendente) +Atualizado em: 2026-07-19 (Fase 1 + `capture-review-debts` pós-`/simplify` — cleanup F1 absorvido; F2 ganha quebra de ciclo de módulo) Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A7) Responsável: — @@ -11,11 +11,13 @@ O A4 ([baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) Fases 2–4) entreg Os revisores (performance / reuse / quality) marcaram como **importantes e maiores que simplify** os follow-ups abaixo. Sem registro, o A5 (insights) e o coroplético do B3 herdariam o mesmo custo de I/O no path do detalhe. -1. **Detalhe puxa todas as linhas nominais de dep. federal (1º turno) na geografia.** `detailVoteWhere` + `loadElectionVotes` em `src/utilities/nucleusElectoralBaseline.ts` buscam o ranking local inteiro para `winnerFederal` e `candidate.rank`. Em núcleo multi-município / TI inteiro isso pode ser milhares de rows por abertura da aba Visão geral (`pagination: false`). +1. **Detalhe puxa todas as linhas nominais de dep. federal (1º turno) na geografia.** ~~`detailVoteWhere` + `loadElectionVotes`~~ **Resolvido na Fase 1 (2026-07-19):** `loadFederalCandidateTotalsAggregated` (`src/utilities/federalCandidateTotalsAggregate.ts`) agrega via drizzle `SUM(votes) GROUP BY candidate_number`; o detalhe usa Local API só para presidente/governador da chapa (`loadTicketOfficeVotes`). A série histórica E2 (`loadCandidateSeriesByGeography`) já era magra por `candidateNumber`. 2. **Filtro geográfico por `cityName` (texto), não por `cityCode` TSE.** O índice natural das collections de eleição é `(year, office, turn, cityCode, zoneNumber)`; filtrar por nome impede o plano ótimo e complica OR grandes na união do overview (já mitigado no simplify, mas o detalhe/TI ainda sofre). 3. **DRY de UI do Gap "acima".** `NucleusInsights` usa `Alert variant="pending"` para abaixo/neutro, mas o estado "acima" aplica classes ad-hoc com tokens `--estimate-confirmed*`. O slice **A5-1 conversão** reutiliza o mesmo `confirmedInsightAlertClass` para o alerta de taxa de conversão (reduto/consolidado) — interim até a Fase 3. A barra do candidato no card também reimplementa `role="progressbar"` em vez de reusar `Progress` (`NucleusListOverview`). -**Explicitamente fora (revisores pediram skip no simplify):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), e o cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4). +**Já resolvido no simplify pós-F1 / `capture-review-debts` (não reabrir):** remoção de `cities` derivado em `NucleusElectionGeography` (só `zonesByCity` + `cityZonePairs`); `assertCanReadElectionData` com `asserts` e entrada `CampaignUser | User | null | undefined`; testes reais de `loadFederalCandidateTotalsAggregated` (mock drizzle) em `federalCandidateTotalsAggregate.unit.spec.ts`; int spy estrutural (`whereContainsField*`) em vez de `JSON.stringify(where)`; inline do SQL builder federal; comentário em `aggregateFederalCandidateTotals` como oracle in-memory para unit tests. + +**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível). ## Objetivos @@ -32,10 +34,11 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior - **Cortável se a geografia real dos núcleos permanecer pequena** (1–2 municípios tipados). Vira não-cortável de qualidade quando A5/B3 passarem a martelar o mesmo helper em TI inteiro. - **Não registrar no A7 o cast `as unknown` do overview B1** — débito de tipagem do select Payload da lista; só tocar se um PR de A7 já estiver no arquivo. - **i18n e naming** (AGENTS.md): identificadores em inglês (`aggregateFederalTotalsByGeography`, `tseCityCodeForMunicipality`, `alertVariants.confirmed`), strings visíveis em pt-BR inalteradas. +- **Impeccable:** Fase 1 = classe **A** (só backend); Fase 2 = **A**; Fase 3 = **B** (encaixe em `Alert`/`NucleusInsights`/`NucleusElectoralBaseline` existentes). ## Questões em aberto -- **Fase 1: Local API `find` + agregação em memória vs `payload.db.drizzle` `GROUP BY`?** **Recomendação:** drizzle (como o seed TSE) para `SUM(votes) GROUP BY candidate_number` no escopo federal T1 + geografia, mais uma query estreita para presidente/governador da chapa; manter `overrideAccess`/ACL lendo via Local API só se o drizzle for privilegiado — espelhar o padrão do import (`overrideAccess: true` só em CLI). No app, preferir Local API com `select` mínimo **ou** uma query drizzle encapsulada que ainda respeite `canReadElectionData` (checagem explícita antes). Definir no PR da F1 com um teste int de TI grande (fixture expandida ou assert de limite de rows). +- **Fase 1: Local API `find` + agregação em memória vs `payload.db.drizzle` `GROUP BY`?** **Resolvido (2026-07-19):** drizzle `GROUP BY candidate_number` em `federalCandidateTotalsAggregate.ts`, com gate ACL explícito (`assertCanReadElectionData`) antes do `execute`; presidente/governador e série histórica permanecem no Local API magro. - **Fase 2: de onde vem o mapa nome canônico → `CD_MUNICIPIO` TSE?** **Recomendação:** extrair do próprio seed/`electionTally`/`electionCandidateVote` (já têm `cityCode`+`cityName`) para uma tabela estática gerada (`src/lib/bahiaTseCityCodes.ts`) no mesmo espírito de `bahiaMunicipalityCodes` (IBGE) — **não** confundir com código IBGE. Script re-executável opcional; sem migration se for só arquivo versionado. - **Fase 3: nome da variante Alert — `confirmed` ou `estimate-confirmed`?** **Recomendação:** `confirmed`, alinhado aos tokens CSS `--estimate-confirmed` / classe utilitária já usada pelo Badge `estimate-confirmed`, sem inventar terceira nomenclatura. @@ -51,19 +54,17 @@ flowchart TD F1 -.métricas.-> B3["B3 Leaflet coroplético"] ``` -### Fase 1 — Agregação do ranking federal no detalhe +### Fase 1 — Agregação do ranking federal no detalhe ✓ (2026-07-19) -- Em `src/utilities/nucleusElectoralBaseline.ts`: deixar de materializar todas as rows nominais federais da geografia só para `aggregateFederalCandidateTotals`. -- Caminho preferido: query agregada (drizzle) → lista `{ candidateNumber, name, party, votes }` já somada; candidato da chapa + winner/rank derivados dessa lista curta (N candidatos distintos, não N×zonas). -- Manter query estreita para presidente/governador da `BASELINE_TICKET_2022` (já filtrada por número). -- Tallies: inalterado (já é 1 row por city×zone federal T1). -- Testes: unit da agregação pura; int com geografia multi-zona assertando winner/rank/solla votes **e** teto de rows transferidas (ou mock do finder). -- Critério: abrir overview de núcleo com ≥2 municípios / TI não escala linearmente com (#candidatos × #zonas) no wire. +- `src/utilities/federalCandidateTotalsAggregate.ts`: drizzle `SUM(votes) GROUP BY candidate_number` + `assertCanReadElectionData`. +- `src/utilities/nucleusElectoralBaseline.ts`: `loadTicketOfficeVotes` (Local API magro) + `aggregateNucleusElectoralBaseline` recebe `federalTotals` já agregados; `getNucleusElectoralBaseline` widened para `Pick`. +- Testes: unit `federalCandidateTotalsAggregate.unit.spec.ts`; int spy em `payload.find` garante que queries federal T1 no Local API sempre filtram `candidateNumber`. ### Fase 2 — Filtro por `cityCode` TSE +- Extrair tipos/helpers de geografia para `src/utilities/nucleusElectionGeography.ts` (quebra o ciclo `federalCandidateTotalsAggregate` ↔ `nucleusElectoralBaseline`; hoje o aggregate importa `NucleusElectionGeography` do baseline). - Gerar/commitar mapa canônico `cityName` → `cityCode` (BA 2022) a partir dos dados já importados. -- `resolveNucleusElectionGeography` (ou camada ao lado) passa a expor `cityZonePairs` com código; `geographyWhere` usa `cityCode: { equals|in }` + `zoneNumber: { in }`. +- `resolveNucleusElectionGeography` (ou camada ao lado) passa a expor `cityZonePairs` com código; `geographyWhere` e `buildFederalCandidateTotalsSql` usam `cityCode: { equals|in }` + `zoneNumber: { in }` (unifica Payload `Where` e SQL cru). - Overview union e detalhe compartilham o mesmo helper. - Sem mudança de UX; fallback fail-closed se o município do núcleo não mapear (mesmo espírito de `canonicalizeMunicipalityName`). @@ -95,7 +96,10 @@ flowchart TD - `docs/roadmap.md` (Trilha A, item A7; A4/A5/B3) - `docs/plans/baseline-eleitoral-tse.md` — plano pai do A3/A4 - `docs/plans/escala-dry-pos-b2.md` / `escala-dry-pos-c3.md` / `escala-dry-pos-c6.md` — precedente pós-`/simplify` -- `src/utilities/nucleusElectoralBaseline.ts` — `detailVoteWhere`, `loadElectionVotes`, `aggregateFederalCandidateTotals` +- `src/utilities/federalCandidateTotalsAggregate.ts` — drizzle aggregate federal T1 (Fase 1) +- `src/utilities/nucleusElectoralBaseline.ts` — `loadTicketOfficeVotes`, `aggregateNucleusElectoralBaseline`, série E2 lean +- `src/utilities/campaignAccess.ts` — `assertCanReadElectionData` / `ElectionDataReader` (gate ACL do path drizzle) +- `src/utilities/drizzleBulk.ts` — `requirePostgresDrizzle` (read-only aggregates) - `docs/plans/insight-taxa-conversao.md` — A5-1 usa `confirmedInsightAlertClass` até F3 - `src/lib/electionInsights.ts` / `src/components/campaign/NucleusInsights.tsx` — Gap + conversão UI - `src/components/campaign/NucleusElectoralBaseline.tsx` — barra do candidato diff --git a/docs/roadmap.md b/docs/roadmap.md index 12ed6128..7ca260f3 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; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E1 metas em cenários + prioridade e E3 dobradinhas/encaminhamentos manuais implementados e mesclados em `main` 2026-07-19** (migration `20260719_054522_add_nucleus_goals_strategy`; `/simplify` → E6); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md); **Field Desk polish** (Impeccable quieter→polish, critique 28→32) em branch local — [PRODUCT.md](../PRODUCT.md) / [DESIGN.md](../DESIGN.md); **FD+ registrado** — escala/DRY pós-Field Desk do `/simplify` — [plano](plans/escala-dry-pos-field-desk.md); **FD2 registrado** — UX pós-critique (bulk fila, glossário, outline, empty coordenador) — [plano](plans/field-desk-ux-pos-critique.md)) +Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); **A7 Fase 1 entregue** (agregação drizzle ranking federal; merge pendente; Fases 2–3 pendentes) — [plano](plans/escala-dry-pos-a4.md); C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E1 metas em cenários + prioridade e E3 dobradinhas/encaminhamentos manuais implementados e mesclados em `main` 2026-07-19** (migration `20260719_054522_add_nucleus_goals_strategy`; `/simplify` → E6); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md); **Field Desk polish** (Impeccable quieter→polish, critique 28→32) em branch local — [PRODUCT.md](../PRODUCT.md) / [DESIGN.md](../DESIGN.md); **FD+ registrado** — escala/DRY pós-Field Desk do `/simplify` — [plano](plans/escala-dry-pos-field-desk.md); **FD2 registrado** — UX pós-critique (bulk fila, glossário, outline, empty coordenador) — [plano](plans/field-desk-ux-pos-critique.md)) Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos. @@ -65,7 +65,7 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de ### Ciclo 2+ — A4 implementado e mesclado em `main` (2026-07-18) - **A4 Baseline no produto + Gap vs 2022** — agregação `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` + `computeGapVs2022`; bloco "Baseline eleitoral 2022" e insight Gap na aba Visão geral do núcleo; card "Baseline 2022" no overview da lista filtrada. Chapa tipada via `BASELINE_TICKET_2022` (papéis `candidate` / `president` / `governor`). Sem migration, sem Consent (dado público TSE). Três passagens `/simplify` no PR; débitos maiores → A7. [Plano](plans/baseline-eleitoral-tse.md). -- **A7 Escala e DRY pós-A4 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: agregar ranking federal do detalhe (não puxar todas as rows nominais), filtro geográfico por `cityCode` TSE, variant Alert `confirmed` + `Progress` no card. [Plano](plans/escala-dry-pos-a4.md). +- **A7 Escala e DRY pós-A4 — Fase 1 entregue (2026-07-19; merge pendente)** — agregação drizzle do ranking federal no detalhe (`loadFederalCandidateTotalsAggregated`); Fases 2–3 pendentes (`cityCode` TSE, Alert `confirmed` + `Progress`). Passagem `/simplify` + `capture-review-debts` absorveu cleanup F1 no plano. [Plano](plans/escala-dry-pos-a4.md). - **A8 Perfis do eleitorado IBGE registrado (2026-07-19)** — perfil médio/comum do território derivado de indicadores municipais do Censo IBGE (artefato estático + ponderação por `cities[]`), exibido na aba Eleitorado junto com `voterProfiles` manuais já existentes; sem migration, sem Consent. [Plano](plans/perfil-eleitorado-ibge.md). ### Ciclo 2+ — B2 fundação de geometrias do mapa (2026-07-18) @@ -149,7 +149,7 @@ flowchart TD A2["A2 Zonas TSE + sugestões cruzadas
(chips opt-in território↔ZE) ✓"] A3["A3 Baseline TSE 2022 — Fase 1
(import, collections) ✓"] A4["A4 Baseline no produto + Gap vs 2022
(detalhe, overview) ✓"] - A7["A7 Escala e DRY pós-A4
(agregar federal / cityCode / Alert)"] + A7["A7 Escala e DRY pós-A4
(F1 agregar federal ✓ / cityCode / Alert)"] A5["A5 Insights: conversão, classificação,
alavancagem (+virada), mobilização, competitiva"] A6["A6 Dobradinha 2026"] A8["A8 Perfis eleitorado IBGE
(médio calculado + manuais)"] @@ -284,7 +284,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (ap | 18 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 ✓ + E3 ✓ (suave: E2 ✓ — tendência visível na revisão; E6 F1 — perf da lista) | A5, A7, A8, B3, B5, C4, D2 | | 19 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto; alavancagem inclui Fase 2 virada por vencedor **ou** share da direita) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, A8, B3, B5, C4, E4, D1 | | 20 | A8 Perfis do eleitorado — perfil médio IBGE (Censo) + manuais na aba Eleitorado _(cortável: manuais já existem)_ | [detalhes](plans/perfil-eleitorado-ibge.md) | A1 ✓ (suave: B2 ✓ — `codarea`) | A5, A7, B3, B5, C4, E4, D1 | -| 21 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, A8, B3, B5, C4, E4, D1 | +| 21 | A7 Escala e DRY pós-A4 (`cityCode` TSE, Alert `confirmed` + Progress) _(Fase 1 agregar federal entregue 2026-07-19; Fases 2–3 pendentes; `/simplify` + `capture-review-debts` absorvidos no plano)_ | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, A8, B3, B5, C4, E4, D1 | | 22 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, A8, B5, C4, E4, D1 | | 23 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, A8, B3, C4, E4, D1 | | 24 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, A8, B3, B5, E4, D1 | diff --git a/src/utilities/campaignAccess.ts b/src/utilities/campaignAccess.ts index 4326b657..eceba113 100644 --- a/src/utilities/campaignAccess.ts +++ b/src/utilities/campaignAccess.ts @@ -390,9 +390,19 @@ export const canManageNucleusLifecycle: FieldAccess = async ({ req }) => { export const canSetDerivedNucleusField: FieldAccess = ({ req }) => isPayloadAdmin(req.user) +export type ElectionDataReader = CampaignUser | User + +export const canReadElectionDataAsUser = (user: CampaignActor): user is ElectionDataReader => + isPayloadAdmin(user) || isCampaignUser(user) + +export function assertCanReadElectionData(user: CampaignActor): asserts user is ElectionDataReader { + if (!canReadElectionDataAsUser(user)) { + throw new Error('Leitura de dados eleitorais negada.') + } +} + /** Public TSE election data: any authenticated campaign or admin user may read. */ -export const canReadElectionData: Access = ({ req }) => - isPayloadAdmin(req.user) || isCampaignUser(req.user) +export const canReadElectionData: Access = ({ req }) => canReadElectionDataAsUser(req.user) /** Election reference data is mutated only by Payload admins (or CLI with overrideAccess). */ export const canMutateElectionData: Access = ({ req }) => isPayloadAdmin(req.user) diff --git a/src/utilities/drizzleBulk.ts b/src/utilities/drizzleBulk.ts index 40f05bcd..5d8519f3 100644 --- a/src/utilities/drizzleBulk.ts +++ b/src/utilities/drizzleBulk.ts @@ -1,5 +1,6 @@ import 'server-only' +import { sql } from '@payloadcms/db-postgres' import type { Payload } from 'payload' export type DrizzleTables = Record> @@ -8,6 +9,10 @@ type PayloadDbTables = { tables: DrizzleTables } +type PostgresExecuteDb = { + execute: (query: ReturnType) => Promise +} + /** ~15-16 columns × 500 ≈ 7500-8000 bind params — well under PG's 65535 param limit. */ export const INSERT_CHUNK_SIZE = 500 @@ -61,3 +66,18 @@ export const drizzleResultRows = (result: unknown): Array, + contextLabel: string, +): PostgresExecuteDb => { + if (payload.db.name !== 'postgres') { + throw new Error(`${contextLabel} exige o adaptador PostgreSQL.`) + } + const drizzle = (payload.db as unknown as { drizzle?: PostgresExecuteDb }).drizzle + if (!drizzle || typeof drizzle.execute !== 'function') { + throw new Error(`A sessão PostgreSQL de ${contextLabel} não está disponível.`) + } + return drizzle +} diff --git a/src/utilities/federalCandidateTotalsAggregate.ts b/src/utilities/federalCandidateTotalsAggregate.ts new file mode 100644 index 00000000..feed3ef3 --- /dev/null +++ b/src/utilities/federalCandidateTotalsAggregate.ts @@ -0,0 +1,60 @@ +import 'server-only' + +import { sql } from '@payloadcms/db-postgres' +import type { Payload } from 'payload' + +import { BASELINE_TICKET_2022, ELECTION_YEAR_2022 } from '@/lib/electionResults' +import { + assertCanReadElectionData, + type ElectionDataReader, +} from '@/utilities/campaignAccess' +import { drizzleResultRows, requirePostgresDrizzle } from '@/utilities/drizzleBulk' +import type { NucleusElectionGeography } from '@/utilities/nucleusElectoralBaseline' + +export type FederalCandidateTotal = { + candidateNumber: number + name: string + party: string + votes: number +} + +export const loadFederalCandidateTotalsAggregated = async ( + payload: Pick, + user: ElectionDataReader, + geography: NucleusElectionGeography, +): Promise => { + assertCanReadElectionData(user) + + const cityClauses = [...geography.zonesByCity.entries()].map(([cityName, zones]) => + sql`("election_candidate_vote"."city_name" = ${cityName} AND "election_candidate_vote"."zone_number" IN (${sql.join( + zones.map((zone) => sql`${zone}`), + sql`, `, + )}))`, + ) + + const drizzle = requirePostgresDrizzle(payload, 'agregação federal') + const result = await drizzle.execute(sql` + SELECT + "election_candidate_vote"."candidate_number" AS "candidate_number", + MAX("election_candidate_vote"."candidate_name") AS "candidate_name", + COALESCE(MAX("election_candidate_vote"."party"), '') AS "party", + SUM("election_candidate_vote"."votes")::int AS "votes" + FROM "election_candidate_vote" + WHERE + "election_candidate_vote"."year" = ${ELECTION_YEAR_2022} + AND "election_candidate_vote"."state" = 'BA' + AND "election_candidate_vote"."office" = ${BASELINE_TICKET_2022.candidate.office} + AND "election_candidate_vote"."turn" = '1' + AND "election_candidate_vote"."vote_type" = 'nominal' + AND (${sql.join(cityClauses, sql` OR `)}) + GROUP BY "election_candidate_vote"."candidate_number" + ORDER BY "votes" DESC, "election_candidate_vote"."candidate_number" ASC + `) + + return drizzleResultRows(result).map((row) => ({ + candidateNumber: Number(row.candidate_number), + name: String(row.candidate_name ?? ''), + party: String(row.party), + votes: Number(row.votes ?? 0), + })) +} diff --git a/src/utilities/nucleusElectoralBaseline.ts b/src/utilities/nucleusElectoralBaseline.ts index 5e04ad54..0073cd0c 100644 --- a/src/utilities/nucleusElectoralBaseline.ts +++ b/src/utilities/nucleusElectoralBaseline.ts @@ -23,8 +23,12 @@ import { type VoteTrendSeries, type VoteTrendStatus, } from '@/lib/electionInsights' -import type { CampaignUser, User } from '@/payload-types' +import { type ElectionDataReader } from '@/utilities/campaignAccess' import { normalizeTerritoryTextArray } from '@/utilities/campaignTerritoryValidation' +import { + loadFederalCandidateTotalsAggregated, + type FederalCandidateTotal, +} from '@/utilities/federalCandidateTotalsAggregate' import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels' import { sortedUniqueZoneNumbers } from '@/utilities/tseZone' @@ -49,7 +53,6 @@ export const toNucleusElectionGeographyInput = (nucleus: { }) export type NucleusElectionGeography = { - cities: string[] zonesByCity: Map cityZonePairs: Array<{ cityName: string; zoneNumber: number }> } @@ -77,8 +80,6 @@ export type ElectionTallyAggregateRow = { abstencoes: number } -type ElectionReader = CampaignUser | User - const cityZoneKey = (cityName: string, zoneNumber: number): string => `${cityName}::${zoneNumber}` @@ -135,7 +136,7 @@ export const resolveNucleusElectionGeography = ( if (cityZonePairs.length === 0) return null - return { cities: [...zonesByCity.keys()], zonesByCity, cityZonePairs } + return { zonesByCity, cityZonePairs } } const matchesGeography = ( @@ -176,14 +177,12 @@ const decisiveTicketVotes = ( return null } -const aggregateFederalCandidateTotals = ( +/** In-memory reference aggregate for unit tests; production uses drizzle GROUP BY. */ +export const aggregateFederalCandidateTotals = ( votes: readonly ElectionVoteAggregateRow[], geography: NucleusElectionGeography, -): Array<{ candidateNumber: number; name: string; party: string; votes: number }> => { - const byCandidate = new Map< - number, - { candidateNumber: number; name: string; party: string; votes: number } - >() +): FederalCandidateTotal[] => { + const byCandidate = new Map() for (const row of votes) { if (row.office !== CANDIDATE_OFFICE || row.turn !== '1') continue @@ -250,11 +249,12 @@ const sumElectorateForGeography = ( export const aggregateNucleusElectoralBaseline = ( geography: NucleusElectionGeography, - votes: readonly ElectionVoteAggregateRow[], + federalTotals: readonly FederalCandidateTotal[], + ticketVotes: readonly ElectionVoteAggregateRow[], tallies: readonly ElectionTallyAggregateRow[], historicalVotes: Pick, ): NucleusElectoralBaselineViewModel => { - const federalTotals = aggregateFederalCandidateTotals(votes, geography) + // `federalTotals` must be sorted by votes desc (SQL aggregate or aggregateFederalCandidateTotals). const candidateIndex = federalTotals.findIndex( (candidate) => candidate.candidateNumber === BASELINE_TICKET_2022.candidate.candidateNumber, ) @@ -268,13 +268,13 @@ export const aggregateNucleusElectoralBaseline = ( rank: candidateIndex >= 0 ? candidateIndex + 1 : null, }, president: decisiveTicketVotes( - votes, + ticketVotes, geography, 'presidente', BASELINE_TICKET_2022.president.candidateNumber, ), governor: decisiveTicketVotes( - votes, + ticketVotes, geography, 'governador', BASELINE_TICKET_2022.governor.candidateNumber, @@ -305,17 +305,14 @@ const geographyWhere = (geography: NucleusElectionGeography): Where => ({ ), }) -/** Votes for the detail baseline card (federal ranking + president/governor tickets). */ -const detailVoteWhere = (geography: NucleusElectionGeography): Where => ({ +/** President/governor ticket votes for the detail baseline card (lean Local API path). */ +const ticketOfficeVoteWhere = (geography: NucleusElectionGeography): Where => ({ and: [ ...ba2022Scope(), { voteType: { equals: 'nominal' } }, geographyWhere(geography), { or: [ - { - and: [{ office: { equals: CANDIDATE_OFFICE } }, { turn: { equals: '1' } }], - }, { and: [ { office: { equals: 'presidente' } }, @@ -333,14 +330,14 @@ const detailVoteWhere = (geography: NucleusElectionGeography): Where => ({ ], }) -const loadElectionVotes = async ( +const loadTicketOfficeVotes = async ( payload: Pick, - user: ElectionReader, + user: ElectionDataReader, geography: NucleusElectionGeography, ): Promise => { const result = await payload.find({ collection: 'electionCandidateVote', - where: detailVoteWhere(geography), + where: ticketOfficeVoteWhere(geography), depth: 0, pagination: false, select: { @@ -371,7 +368,7 @@ const loadElectionVotes = async ( const loadElectionTallies = async ( payload: Pick, - user: ElectionReader, + user: ElectionDataReader, geography: NucleusElectionGeography, ): Promise => { const result = await payload.find({ @@ -442,7 +439,6 @@ const buildUnionGeography = ( } return { - cities: uniqueSortedCities(zonesByCity.keys()), zonesByCity, cityZonePairs: [...unionPairs.values()], } @@ -461,7 +457,7 @@ const sumCandidateVotesForGeography = ( /** Candidate-only votes indexed by year then city×zone (E2 lean loader). */ const loadCandidateSeriesByGeography = async ( payload: Pick, - user: ElectionReader, + user: ElectionDataReader, geography: NucleusElectionGeography, years: readonly number[] = [...HISTORICAL_SERIES_YEARS], ): Promise>> => { @@ -522,15 +518,16 @@ const sumCandidateSeriesForGeography = ( } export const getNucleusElectoralBaseline = async ( - payload: Pick, - user: ElectionReader, + payload: Pick, + user: ElectionDataReader, nucleus: NucleusElectionGeographyInput, ): Promise => { const geography = resolveNucleusElectionGeography(nucleus) if (!geography) return null - const [votes, tallies, seriesByYear] = await Promise.all([ - loadElectionVotes(payload, user, geography), + const [federalTotals, ticketVotes, tallies, seriesByYear] = await Promise.all([ + loadFederalCandidateTotalsAggregated(payload, user, geography), + loadTicketOfficeVotes(payload, user, geography), loadElectionTallies(payload, user, geography), loadCandidateSeriesByGeography(payload, user, geography, HISTORICAL_PRIOR_SERIES_YEARS), ]) @@ -540,7 +537,13 @@ export const getNucleusElectoralBaseline = async ( seriesByYear, HISTORICAL_PRIOR_SERIES_YEARS, ) - return aggregateNucleusElectoralBaseline(geography, votes, tallies, historicalVotes) + return aggregateNucleusElectoralBaseline( + geography, + federalTotals, + ticketVotes, + tallies, + historicalVotes, + ) } export type NucleusBaseline2022OverviewInput = NucleusElectionGeographyInput & { @@ -571,7 +574,7 @@ export type NucleusListElectionOverview = { */ export const loadNucleusListElectionOverview = async ( payload: Pick, - user: ElectionReader, + user: ElectionDataReader, nuclei: readonly NucleusBaseline2022OverviewInput[], ): Promise => { const geographies = nuclei.map((nucleus) => resolveNucleusElectionGeography(nucleus)) diff --git a/tests/int/nucleusElectoralBaseline.int.spec.ts b/tests/int/nucleusElectoralBaseline.int.spec.ts index 704dbe6c..16b47c30 100644 --- a/tests/int/nucleusElectoralBaseline.int.spec.ts +++ b/tests/int/nucleusElectoralBaseline.int.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { getPayload, type Payload } from 'payload' import config from '@/payload.config' @@ -17,6 +17,33 @@ import { import { loadTseFixtureResults, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures' import { installCampaignFixtures } from '../helpers/campaignFixtures' +const whereContainsFieldEquals = (node: unknown, field: string, value: unknown): boolean => { + if (!node || typeof node !== 'object') return false + if (Array.isArray(node)) return node.some((item) => whereContainsFieldEquals(item, field, value)) + + const record = node as Record + const fieldValue = record[field] + if ( + fieldValue && + typeof fieldValue === 'object' && + 'equals' in fieldValue && + (fieldValue as { equals: unknown }).equals === value + ) { + return true + } + + return Object.values(record).some((child) => whereContainsFieldEquals(child, field, value)) +} + +const whereContainsField = (node: unknown, field: string): boolean => { + if (!node || typeof node !== 'object') return false + if (Array.isArray(node)) return node.some((item) => whereContainsField(item, field)) + + const record = node as Record + if (field in record) return true + return Object.values(record).some((child) => whereContainsField(child, field)) +} + let payload: Payload const campaignFixtures = installCampaignFixtures({ getPayload: () => payload, @@ -91,6 +118,44 @@ describe('nucleus electoral baseline (A4)', () => { expect(BASELINE_TICKET_2022.candidate.candidateNumber).toBe(1313) }) + it('does not fetch all federal T1 vote rows via Local API for the detail baseline', async () => { + const fixtures = campaignFixtures() + const user = await fixtures.createCampaignUser('geral', { + name: fixtures.value('Baseline aggregate geral'), + email: `${fixtures.value('baseline-aggregate-geral')}@example.com`, + password: fixtures.value('password'), + }) + + const findSpy = vi.spyOn(payload, 'find') + + await getNucleusElectoralBaseline(payload, user, { + cities: ['Salvador'], + regions: ['Metropolitano de Salvador'], + tseZones: [1, 2], + }) + + const voteFindCalls = findSpy.mock.calls.filter( + (call) => + typeof call[0] === 'object' && + call[0] !== null && + (call[0] as { collection?: string }).collection === 'electionCandidateVote', + ) + + expect(voteFindCalls.length).toBeGreaterThan(0) + + for (const call of voteFindCalls) { + const where = (call[0] as { where?: unknown }).where + const queriesFederalTurn1 = + whereContainsFieldEquals(where, 'office', 'deputado_federal') && + whereContainsFieldEquals(where, 'turn', '1') + if (queriesFederalTurn1) { + expect(whereContainsField(where, 'candidateNumber')).toBe(true) + } + } + + findSpy.mockRestore() + }) + it('returns null without geography and loads baseline on the overview tab', async () => { const fixtures = campaignFixtures() const user = await fixtures.createCampaignUser('coordenador', { diff --git a/tests/unit/federalCandidateTotalsAggregate.unit.spec.ts b/tests/unit/federalCandidateTotalsAggregate.unit.spec.ts new file mode 100644 index 00000000..d2bd1079 --- /dev/null +++ b/tests/unit/federalCandidateTotalsAggregate.unit.spec.ts @@ -0,0 +1,84 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vitest' +import type { Payload } from 'payload' + +import type { CampaignUser, User } from '@/payload-types' +import { assertCanReadElectionData } from '@/utilities/campaignAccess' +import { loadFederalCandidateTotalsAggregated } from '@/utilities/federalCandidateTotalsAggregate' +import * as drizzleBulk from '@/utilities/drizzleBulk' +import { resolveNucleusElectionGeography } from '@/utilities/nucleusElectoralBaseline' + +describe('assertCanReadElectionData', () => { + it('allows payload admins and campaign users to read election data', () => { + expect(() => + assertCanReadElectionData({ + collection: 'users', + id: 1, + } as User), + ).not.toThrow() + + expect(() => + assertCanReadElectionData({ + collection: 'campaignUser', + id: 1, + role: 'lideranca', + } as CampaignUser), + ).not.toThrow() + }) + + it('denies readers outside campaign/admin collections', () => { + expect(() => + assertCanReadElectionData({ + collection: 'contact', + id: 1, + } as never), + ).toThrow('Leitura de dados eleitorais negada.') + }) +}) + +describe('loadFederalCandidateTotalsAggregated', () => { + it('maps drizzle aggregate rows and scopes geography in SQL', async () => { + const geography = resolveNucleusElectionGeography({ + cities: ['Salvador'], + regions: [], + tseZones: [1, 2], + })! + + const execute = vi.fn().mockResolvedValue({ + rows: [ + { + candidate_number: 1313, + candidate_name: 'JORGE SOLLA', + party: 'PT', + votes: 2100, + }, + { + candidate_number: 1234, + candidate_name: 'OUTRO', + party: 'PL', + votes: 900, + }, + ], + }) + vi.spyOn(drizzleBulk, 'requirePostgresDrizzle').mockReturnValue({ execute }) + + const payload = { db: { name: 'postgres' } } as Pick + const totals = await loadFederalCandidateTotalsAggregated( + payload, + { + collection: 'campaignUser', + id: 1, + role: 'geral', + } as CampaignUser, + geography, + ) + + expect(totals).toEqual([ + { candidateNumber: 1313, name: 'JORGE SOLLA', party: 'PT', votes: 2100 }, + { candidateNumber: 1234, name: 'OUTRO', party: 'PL', votes: 900 }, + ]) + expect(execute).toHaveBeenCalledOnce() + vi.restoreAllMocks() + }) +}) diff --git a/tests/unit/nucleusElectoralBaseline.unit.spec.ts b/tests/unit/nucleusElectoralBaseline.unit.spec.ts index 9e51dc35..b2482009 100644 --- a/tests/unit/nucleusElectoralBaseline.unit.spec.ts +++ b/tests/unit/nucleusElectoralBaseline.unit.spec.ts @@ -7,12 +7,16 @@ import { tseZonesForCity } from '@/lib/bahiaTseZones' import { BASELINE_TICKET_2022 } from '@/lib/electionResults' import { computeConversionRate, computeVoteTrend } from '@/lib/electionInsights' import { + aggregateFederalCandidateTotals, aggregateNucleusElectoralBaseline, resolveNucleusElectionGeography, type ElectionTallyAggregateRow, type ElectionVoteAggregateRow, } from '@/utilities/nucleusElectoralBaseline' +const ticketOfficeVotes = (votes: readonly ElectionVoteAggregateRow[]) => + votes.filter((row) => row.office === 'presidente' || row.office === 'governador') + describe('resolveNucleusElectionGeography', () => { it('intersects nucleus tseZones with official city zones', () => { const salvadorZones = tseZonesForCity('Salvador') @@ -27,7 +31,7 @@ describe('resolveNucleusElectionGeography', () => { }) expect(geography).not.toBeNull() - expect(geography?.cities).toEqual(['Salvador']) + expect([...geography!.zonesByCity.keys()]).toEqual(['Salvador']) expect(geography?.zonesByCity.get('Salvador')).toEqual([firstZone]) expect(geography?.cityZonePairs).toEqual([{ cityName: 'Salvador', zoneNumber: firstZone }]) expect(geography?.zonesByCity.get('Salvador')).not.toContain(secondZone) @@ -53,7 +57,9 @@ describe('resolveNucleusElectionGeography', () => { }) expect(geography).not.toBeNull() - expect(geography?.cities).toEqual([...cities].sort((a, b) => a.localeCompare(b, 'pt-BR'))) + expect([...geography!.zonesByCity.keys()].sort((a, b) => a.localeCompare(b, 'pt-BR'))).toEqual( + [...cities].sort((a, b) => a.localeCompare(b, 'pt-BR')), + ) expect(geography?.zonesByCity.get('Bom Jesus da Lapa')).toEqual([ ...tseZonesForCity('Bom Jesus da Lapa'), ]) @@ -177,10 +183,17 @@ describe('aggregateNucleusElectoralBaseline', () => { ] it('sums the candidate, ticket leaders, electorate, winner, rank, and series for the geography', () => { - const baseline = aggregateNucleusElectoralBaseline(geography, votes, tallies, { + const federalTotals = aggregateFederalCandidateTotals(votes, geography) + const baseline = aggregateNucleusElectoralBaseline( + geography, + federalTotals, + ticketOfficeVotes(votes), + tallies, + { y2014: 1000, y2018: 1500, - }) + }, + ) expect(baseline.candidate).toEqual({ votes: 2100, rank: 1 }) expect(baseline.president).toEqual({ votes: 8000, turn: 2 }) @@ -208,10 +221,16 @@ describe('aggregateNucleusElectoralBaseline', () => { regions: [], tseZones: [2], })! - const baseline = aggregateNucleusElectoralBaseline(zone1Only, votes, tallies, { + const baseline = aggregateNucleusElectoralBaseline( + zone1Only, + aggregateFederalCandidateTotals(votes, zone1Only), + ticketOfficeVotes(votes), + tallies, + { y2014: 0, y2018: 0, - }) + }, + ) expect(baseline.candidate).toEqual({ votes: 900, rank: 2 }) expect(baseline.winnerFederal).toEqual({ @@ -222,10 +241,16 @@ describe('aggregateNucleusElectoralBaseline', () => { }) it('supports conversion rate from electorate aptos and confirmed estimate', () => { - const baseline = aggregateNucleusElectoralBaseline(geography, votes, tallies, { + const baseline = aggregateNucleusElectoralBaseline( + geography, + aggregateFederalCandidateTotals(votes, geography), + ticketOfficeVotes(votes), + tallies, + { y2014: 0, y2018: 0, - }) + }, + ) expect( computeConversionRate({