Add TSE 2022 election results model and local seed import.

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-18 18:25:19 -03:00
parent 040c36bed7
commit c42b16baab
33 changed files with 10255 additions and 96 deletions

3
.gitignore vendored
View file

@ -42,6 +42,9 @@ next-env.d.ts
/media
# TSE open-data zip cache (pnpm db:seed:tse)
/data/tse/
# Playwright
node_modules/
/test-results/

View file

@ -1,8 +1,8 @@
# Baseline eleitoral TSE 2022
Status: rascunho
Atualizado em: 2026-07-17
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
Status: Fase 1 (A3) implementada — modelo + import
Atualizado em: 2026-07-18
Item do roadmap: [docs/roadmap.md](../roadmap.md) (A3/A4 Baseline TSE 2022)
Responsável: —
## Referência visual (UX Pilot)
@ -43,13 +43,16 @@ Candidatos de interesse (destaques na UI): **Solla = dep. federal BA, PT, nº 13
- **Access control:** dado público TSE. `read` para autenticado em `campaignUser` ou `users` (não aberto ao público). `create/update/delete` admin only. Sem `Consent`, sem transação em runtime. Import via CLI com `overrideAccess: true` fora do runtime deployado.
- **Sem PII.** Não persistir CPF nem título eleitoral. A identidade cross-ano usa `identityKey = sha256(normalize(urnaName) + birthCity + birthState + party)` — só dados públicos da consulta de candidaturas.
- **Migration** `add_election_results` (push:false). `pnpm build` aplica em prod no deploy. Não esbarra no bloqueador LGPD (dado público, sem liderança real).
- **Import idempotente** pela chave única (upsert), com proveniência (URL, data, SHA-256) no cabeçalho do script. Sem step de revalidate (páginas de campanha são dinâmicas com auth, sem ISR/tag).
- **Import idempotente por escopo** `(year, office, turn)`: delete + bulk insert via `payload.db.drizzle` (não upsert row a row). Proveniência (URL, SHA-256) no cabeçalho de `scripts/seed-tse-results.mjs`. Sem step de revalidate (páginas de campanha são dinâmicas com auth, sem ISR/tag).
- **Sem votos de legenda na v1.** Campo `voteType` permanece no schema; só rows `nominal` são importadas. O agregado `votosLegenda` do tally cobre o que os insights precisam. Dataset `votacao_partido_munzona` fica fora do escopo A3.
- **Votos anulados sub judice (2022):** `electionCandidateVote.votes` ← `QT_VOTOS_NOMINAIS` (apurados); validade fica no tally (`votosNominaisValidos`, `votosAnulados`).
- **Mapeamento de município:** `NM_MUNICIPIO` (TSE) → nome canônico de `bahiaTerritories.ts` via normalização case/acento/apóstrofo; falha alta em município não mapeado; persiste `cityCode` (TSE) + `cityName` canônico. Teste cobre os 417 municípios.
- **Agregação núcleo→baseline é autossuficiente** (revisão 2026-07-17): `citiesForTerritory` **já existe** em `src/lib/bahiaTerritories.ts` (não vem do plano `zonas-por-municipio.md`), e a correspondência cidade↔zona pode ser resolvida pelas próprias rows de `electionTally` (cada par cidade×zona importado é uma row — `where cityName = C` retorna todas as zonas da cidade). `tseZonesForCity` (plano `zonas-por-municipio.md`) é **opcional**: quando existir, vira a fonte preferida por ser validada por fixture. Sem `tseZones` → todas as zonas da(s) cidade(s); só `region` → todas as cidades do território; sem geografia → estado "sem baseline TSE".
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês, strings visíveis em pt-BR.
## Questões em aberto
- Mapeamento `CD_MUNICIPIO` (TSE) → nome canônico em `CitiesByState.BA`: usar `NM_MUNICIPIO` do CSV + validação cruzada.
- ~~Mapeamento `CD_MUNICIPIO` (TSE) → nome canônico~~ — resolvido na Fase 1 via `canonicalizeMunicipalityName` + `cityCode` persistido.
- Limiares de classificação territorial (defesa/ataque/indecisa/perdida) — definir com produto.
- Mostrar 1º e 2º turno para presidente/governador, ou só o decisivo? Recomendação: ambos, com destaque ao decisivo.
- `lideranca` vê ranking/competidor? Recomendação: sim, é dado público e útil ao engajamento.
@ -96,7 +99,7 @@ flowchart LR
### Fases
1. **Modelo + import** — collections, migration, `scripts/seed-tse-results.mjs` + `pnpm db:seed:tse`, helpers `src/lib/electionResults.ts` e `src/lib/electionCandidateIdentity.ts`, teste int (totais conhecidos, `elected=true` para Solla/Lula/Jerônimo, idempotência).
1. **Modelo + import** — **feito (A3, 2026-07-18).** Collections `electionTally` / `electionCandidateVote` / `electionCandidate`, migration `20260718_195854_add_election_results`, `pnpm db:seed:tse`, helpers em `src/lib/electionResults*.ts` + `electionCandidateIdentity.ts`, import drizzle em `src/utilities/electionResultsImport.ts`, testes unit/int com fixtures em `tests/fixtures/tse/`. Seed local BA 2022: **211.014** votos nominais, **2.700** apurações, **1.654** candidatos (6 escopos office×turn).
2. **Detalhe do núcleo** — bloco "Baseline eleitoral 2022" na aba overview; view model `NucleusElectoralBaselineViewModel` em `src/utilities/nucleusViewModels.ts`; componente `src/components/campaign/NucleusElectoralBaseline.tsx`.
3. **Overview da lista** — estende `overview-lista-nucleos.md`: somar baselines sobre o conjunto filtrado (`buildNucleusListWhere`); novo bloco no `NucleusListOverview`.
4. **Insights automáticos (Gap vs 2022)** — implementa somente o insight "Gap vs 2022" (ver seção "Insights automáticos" abaixo). `src/components/campaign/NucleusInsights.tsx` + `src/lib/electionInsights.ts`. Os demais insights viram itens separados no roadmap com planos próprios.
@ -167,8 +170,12 @@ Cada insight é uma **derivação de leitura** (sem escrita, sem `Consent`, sem
## Arquivos a criar/alterar
- Criar: `src/collections/ElectionTally.ts`, `src/collections/ElectionCandidateVote.ts`, `src/collections/ElectionCandidate.ts`, `src/lib/electionResults.ts`, `src/lib/electionInsights.ts`, `src/lib/electionCandidateIdentity.ts`, `src/utilities/nucleusElectoralBaseline.ts`, `src/components/campaign/NucleusElectoralBaseline.tsx`, `src/components/campaign/NucleusInsights.tsx`, `scripts/seed-tse-results.mjs`, `scripts/reconcile-running-again.mjs`, migration `add_election_results`, testes int. (Os demais componentes de insight — `electionAlliances.ts`, `DobradinhaOpportunities.tsx` etc. — ficam nos planos separados dos respectivos insights.)
- Alterar: `src/payload.config.ts`, `src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx` + `src/utilities/nucleusDetailPageData.ts`, `src/utilities/nucleusPageData.ts`/`nucleusViewModels.ts`, overview da lista, `package.json` (`db:seed:tse`).
**Fase 1 (A3) — feitos:**
- Criar: `src/collections/ElectionTally.ts`, `ElectionCandidateVote.ts`, `ElectionCandidate.ts`; `src/lib/electionResults.ts`, `electionResultsParse.ts`, `electionResultsBuild.ts`, `electionResultsCsv.ts`, `electionResultsZip.ts`, `electionCandidateIdentity.ts`; `src/utilities/electionResultsImport.ts`; `scripts/seed-tse-results.mjs`; migration `20260718_195854_add_election_results`; fixtures `tests/fixtures/tse/*`; testes unit/int.
- Alterar: `src/payload.config.ts`, `src/utilities/campaignAccess.ts` (`canReadElectionData` / `canMutateElectionData`), `package.json` (`db:seed:tse`), `scripts/seed-loader.mjs` (stub `server-only`), `.gitignore` (`/data/tse/`).
**Fases 2–5 (A4+) — ainda a criar:**
- `src/lib/electionInsights.ts`, `src/utilities/nucleusElectoralBaseline.ts`, `src/components/campaign/NucleusElectoralBaseline.tsx`, `NucleusInsights.tsx`, `scripts/reconcile-running-again.mjs`; alterar páginas do núcleo / overview.
## Dependências

View file

@ -82,7 +82,7 @@ flowchart TD
subgraph TrilhaA["Trilha A — dados eleitorais e território"]
A1["A1 Território multi-município/bairro<br/>(migration)"]
A2["A2 Zonas TSE por município<br/>(auto-preenchimento)"]
A3["A3 Baseline TSE 2022 — Fase 1<br/>(import, collections)"]
A3["A3 Baseline TSE 2022 — Fase 1<br/>(import, collections) ✓"]
A4["A4 Baseline no produto + Gap vs 2022<br/>(detalhe, overview)"]
A5["A5 Insights: conversão, classificação,<br/>alavancagem, mobilização, competitiva"]
A6["A6 Dobradinha 2026"]
@ -130,7 +130,7 @@ flowchart TD
JUR -.chave de push.-> D2
```
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): A1, A3, B1, B2, C1, C2 (engenharia — produção espera o jurídico), D1, além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase).
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): A1, B1, B2, C1, C2 (engenharia — produção espera o jurídico), D1, além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **A3 entregue** (2026-07-18) — collections + `pnpm db:seed:tse`.
### Sequência de execução por janela do calendário
@ -143,7 +143,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): A1, A3, B1,
| 3 | A2 Zonas TSE por município (auto-preenchimento) | [detalhes](plans/zonas-por-municipio.md) | A1 (nasce contra `cities[]`) | B1, C1, A3 |
| 4 | B1 Overview da lista de núcleos | [detalhes](plans/overview-lista-nucleos.md) | — | A1, A2, C1 |
| 5 | C1 Compartilhar página (quick win de campo) | [detalhes](plans/compartilhar-pagina.md) | — | tudo |
| 6 | A3 Baseline TSE 2022 — Fase 1 (import) | [detalhes](plans/baseline-eleitoral-tse.md) | — (dado público; sem bloqueador LGPD) | tudo |
| 6 | A3 Baseline TSE 2022 — Fase 1 (import) **feito** | [detalhes](plans/baseline-eleitoral-tse.md) | — (dado público; sem bloqueador LGPD) | tudo |
**Janela 2 — 05/08 → 16/08 (pré-propaganda): base nominal + inteligência + agenda prontas para o arranque.**

View file

@ -26,6 +26,7 @@
"db:stop": "docker compose down",
"db:pull": "node scripts/db-pull.mjs",
"db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs",
"db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs",
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap",
@ -89,8 +90,10 @@
"@types/node": "22.19.9",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/yauzl": "^3.4.0",
"@vercel/blob": "2.3.1",
"@vitejs/plugin-react": "4.5.2",
"csv-parse": "^7.0.1",
"eslint": "^9.39.2",
"eslint-config-next": "15.4.11",
"eslint-config-prettier": "^10.1.8",
@ -104,7 +107,8 @@
"typescript": "5.7.3",
"typescript-eslint": "^8.55.0",
"vite-tsconfig-paths": "6.0.5",
"vitest": "4.0.18"
"vitest": "4.0.18",
"yauzl": "^3.4.0"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",

View file

@ -138,12 +138,18 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/yauzl':
specifier: ^3.4.0
version: 3.4.0
'@vercel/blob':
specifier: 2.3.1
version: 2.3.1
'@vitejs/plugin-react':
specifier: 4.5.2
version: 4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.2))
csv-parse:
specifier: ^7.0.1
version: 7.0.1
eslint:
specifier: ^9.39.2
version: 9.39.2(jiti@2.6.1)
@ -186,6 +192,9 @@ importers:
vitest:
specifier: 4.0.18
version: 4.0.18(@types/node@22.19.9)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.9)(typescript@5.7.3))(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.2)
yauzl:
specifier: ^3.4.0
version: 3.4.0
packages:
@ -2828,6 +2837,9 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@types/yauzl@3.4.0':
resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==}
'@typescript-eslint/eslint-plugin@8.55.0':
resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -3498,6 +3510,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
csv-parse@7.0.1:
resolution: {integrity: sha512-+2z7Ar0APQ7Uu6fX4cn+pitRmxjZ1WPBcGmZFKmA74FCyi7Et/XZx8cjNQ5CjbZ4HCOxXCOpRBYvYH08Qa003A==}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@ -5240,6 +5255,9 @@ packages:
peerDependencies:
graphql: ^16.8.1
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
@ -6438,6 +6456,10 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yauzl@3.4.0:
resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==}
engines: {node: '>=12'}
yjs@13.6.29:
resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
@ -9098,6 +9120,10 @@ snapshots:
dependencies:
'@types/node': 22.19.9
'@types/yauzl@3.4.0':
dependencies:
'@types/node': 22.19.9
'@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@ -9750,6 +9776,8 @@ snapshots:
csstype@3.2.3: {}
csv-parse@7.0.1: {}
damerau-levenshtein@1.0.8: {}
data-uri-to-buffer@4.0.1: {}
@ -11755,6 +11783,8 @@ snapshots:
- typescript
- utf-8-validate
pend@1.2.0: {}
pg-cloudflare@1.3.0:
optional: true
@ -13155,6 +13185,10 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
yauzl@3.4.0:
dependencies:
pend: 1.2.0
yjs@13.6.29:
dependencies:
lib0: 0.2.117

View file

@ -0,0 +1,45 @@
/**
* Shared local-DB guard for CLI scripts (seeds, pnpm dev guard, etc.).
* Host allowlist + ALLOW_REMOTE_DB escape hatch.
*/
const OVERRIDE_FLAG = 'ALLOW_REMOTE_DB'
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres'])
/**
* @param {string} label Log/error prefix, e.g. "seed:tse"
* @param {string} [usageHint] Extra lines shown when refusing a remote host
*/
export const assertLocalDatabase = (label, usageHint = '') => {
const die = (message) => {
console.error(`\n[${label}] ${message}\n`)
process.exit(1)
}
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) die('DATABASE_URL is not set. Refusing to continue.')
if (process.env[OVERRIDE_FLAG] === 'true' || process.env[OVERRIDE_FLAG] === '1') {
console.warn(
`\n[${label}] ${OVERRIDE_FLAG} is set — connecting to a remote database on purpose. Be careful.\n`,
)
return
}
let host
try {
host = new URL(databaseUrl).hostname
} catch {
die('DATABASE_URL is not a valid connection string.')
}
if (!LOCAL_HOSTS.has(host)) {
die(
`DATABASE_URL points at a non-local host ("${host}").\n` +
(usageHint ? `${usageHint}\n\n` : '') +
`If you REALLY mean to use a remote database, re-run with:\n ${OVERRIDE_FLAG}=true …`,
)
}
console.log(`[${label}] OK — using local database host "${host}".`)
}

View file

@ -9,8 +9,7 @@
*/
import { config as loadEnv } from 'dotenv'
const OVERRIDE_FLAG = 'ALLOW_REMOTE_DB'
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres'])
import { assertLocalDatabase } from './assert-local-database.mjs'
// Mirror Next.js precedence (.env.local wins over .env) WITHOUT overriding a
// DATABASE_URL that is already set in the real environment (e.g. injected by
@ -18,40 +17,10 @@ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgr
loadEnv({ path: '.env.local' })
loadEnv({ path: '.env' })
const fail = (message) => {
console.error(`\n[guard-dev-db] ${message}\n`)
process.exit(1)
}
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
fail('DATABASE_URL is not set. Refusing to start the dev server.')
}
if (process.env[OVERRIDE_FLAG] === 'true' || process.env[OVERRIDE_FLAG] === '1') {
console.warn(
`\n[guard-dev-db] ${OVERRIDE_FLAG} is set — connecting to a remote database on purpose. Be careful.\n`,
)
process.exit(0)
}
let host
try {
host = new URL(databaseUrl).hostname
} catch {
fail('DATABASE_URL is not a valid connection string.')
}
if (!LOCAL_HOSTS.has(host)) {
fail(
`DATABASE_URL points at a non-local host ("${host}").\n` +
'This is almost certainly the production or a shared database.\n\n' +
'Local development should use the local Postgres from docker-compose:\n' +
' 1. pnpm db:start\n' +
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local\n\n' +
`If you REALLY mean to connect to a remote database, re-run with:\n ${OVERRIDE_FLAG}=true pnpm dev`,
)
}
console.log(`[guard-dev-db] OK — using local database host "${host}".`)
assertLocalDatabase(
'guard-dev-db',
'This is almost certainly the production or a shared database.\n\n' +
'Local development should use the local Postgres from docker-compose:\n' +
' 1. pnpm db:start\n' +
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local',
)

View file

@ -1,5 +1,5 @@
/**
* Seed-only Node module loader: stubs `next/cache` with no-ops.
* Seed-only Node module loader: stubs `next/cache` and `server-only`.
*
* The `post` and `tag` collections call `revalidateTag()` (via
* `src/utilities/documents.ts`) inside their `afterChange` hooks. `revalidateTag`
@ -8,12 +8,16 @@
* every `payload.create`. Rather than editing the collections (owned elsewhere),
* we replace `next/cache` with harmless no-ops for the duration of the seed.
*
* Registered via `--import=./scripts/seed-loader.mjs` in the `db:seed:posts`
* npm script; it affects only that process.
* Campaign collections also pull in utilities marked `import 'server-only'`; that
* package throws outside the Next.js server runtime, so we stub it as an empty
* module for CLI seeds (`db:seed:posts`, `db:seed:tse`).
*
* Registered via `--import=./scripts/seed-loader.mjs` in the seed npm scripts;
* it affects only that process.
*/
import { register } from 'node:module'
const shim =
const nextCacheShim =
'data:text/javascript,' +
encodeURIComponent(
[
@ -24,12 +28,17 @@ const shim =
].join('\n'),
)
const serverOnlyShim = 'data:text/javascript,' + encodeURIComponent('export {};')
const hooks =
'data:text/javascript,' +
encodeURIComponent(
`export function resolve(specifier, context, nextResolve) {
if (specifier === 'next/cache') {
return { url: ${JSON.stringify(shim)}, shortCircuit: true }
return { url: ${JSON.stringify(nextCacheShim)}, shortCircuit: true }
}
if (specifier === 'server-only') {
return { url: ${JSON.stringify(serverOnlyShim)}, shortCircuit: true }
}
return nextResolve(specifier, context)
}`,

View file

@ -29,6 +29,8 @@ import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-
import { del as blobDel } from '@vercel/blob'
import { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs'
// Mirror Next.js precedence (.env.local wins over .env) without clobbering a
// DATABASE_URL already set in the real environment.
loadEnv({ path: '.env.local' })
@ -36,49 +38,11 @@ loadEnv({ path: '.env' })
const config = (await import('../src/payload.config.ts')).default
// ---------------------------------------------------------------------------
// DB-safety guard (same shape as scripts/guard-dev-db.mjs)
// ---------------------------------------------------------------------------
const OVERRIDE_FLAG = 'ALLOW_REMOTE_DB'
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres'])
const die = (message) => {
console.error(`\n[seed:posts] ${message}\n`)
process.exit(1)
}
const assertLocalDatabase = () => {
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) die('DATABASE_URL is not set. Refusing to seed.')
if (process.env[OVERRIDE_FLAG] === 'true' || process.env[OVERRIDE_FLAG] === '1') {
console.warn(
`\n[seed:posts] ${OVERRIDE_FLAG} is set — seeding a remote database on purpose. Be careful.\n`,
)
return
}
let host
try {
host = new URL(databaseUrl).hostname
} catch {
die('DATABASE_URL is not a valid connection string.')
}
if (!LOCAL_HOSTS.has(host)) {
die(
`DATABASE_URL points at a non-local host ("${host}").\n` +
'This seed WRITES data (posts/tags/media) and is meant for the local Postgres.\n\n' +
' 1. pnpm db:start\n' +
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local\n\n' +
`If you REALLY mean to seed a remote database, re-run with:\n ${OVERRIDE_FLAG}=true pnpm db:seed:posts`,
)
}
console.log(`[seed:posts] OK — using local database host "${host}".`)
}
// ---------------------------------------------------------------------------
// Taxonomy (Section 7): the categories + control tag, and the per-article map.
// Keys are the WordPress slugs of the live articles. Every post gets exactly
@ -450,7 +414,12 @@ async function ensureCoverMedia(payload, { slug, coverUrl, alt }) {
// ---------------------------------------------------------------------------
async function main() {
assertLocalDatabase()
assertLocalDatabase(
'seed:posts',
'This seed WRITES data (posts/tags/media) and is meant for the local Postgres.\n' +
' 1. pnpm db:start\n' +
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local',
)
const payload = await getPayload({ config })
const editorConfig = await editorConfigFactory.default({ config: payload.config })

View file

@ -0,0 +1,173 @@
/**
* Seeds electionTally / electionCandidateVote / electionCandidate from TSE
* open data for the 2022 general election (Bahia scope).
*
* Provenance (do not strip):
* - Portal: https://dadosabertos.tse.jus.br/dataset/resultados-2022
* - Portal: https://dadosabertos.tse.jus.br/dataset/candidatos-2022
* - votacao_candidato_munzona_2022.zip
* https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2022.zip
* - detalhe_votacao_munzona_2022.zip
* https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip
* - consulta_cand_2022.zip
* https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_2022.zip
* - License: Creative Commons Atribuição (dados abertos TSE)
* - electionCandidateVote.votes ← QT_VOTOS_NOMINAIS (votos apurados; validade no tally)
* - v1: only voteType=nominal (no votacao_partido_munzona / legenda rows)
*
* Safety: refuses non-local DATABASE_URL unless ALLOW_REMOTE_DB=true.
* Idempotent: replace-by-scope (year, office, turn) via drizzle transaction.
*
* Usage:
* pnpm db:seed:tse
* ALLOW_REMOTE_DB=true pnpm db:seed:tse
* TSE_CACHE_DIR=./data/tse pnpm db:seed:tse # reuse downloaded zips
*/
import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile, access } from 'node:fs/promises'
import { join } from 'node:path'
import { config as loadEnv } from 'dotenv'
import { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs'
loadEnv({ path: '.env.local' })
loadEnv({ path: '.env' })
const config = (await import('../src/payload.config.ts')).default
const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts')
const { buildElectionResultsFromCsvRows } = await import('../src/lib/electionResultsBuild.ts')
const { downloadToBuffer, readZipEntry } = await import('../src/lib/electionResultsZip.ts')
const { buildImportBundles, importElectionBundles } = await import(
'../src/utilities/electionResultsImport.ts'
)
const SOURCES = [
{
key: 'votacao_candidato_munzona_2022',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2022.zip',
},
{
key: 'detalhe_votacao_munzona_2022',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip',
},
{
key: 'consulta_cand_2022',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_2022.zip',
},
]
const die = (message) => {
console.error(`\n[seed:tse] ${message}\n`)
process.exit(1)
}
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
const cacheDir = () => process.env.TSE_CACHE_DIR || join(process.cwd(), 'data', 'tse')
const ensureCachedZip = async ({ key, url }) => {
const dir = cacheDir()
await mkdir(dir, { recursive: true })
const path = join(dir, `${key}.zip`)
try {
await access(path)
console.log(`[seed:tse] cache hit ${path}`)
const buffer = await readFile(path)
return { key, url, buffer, hash: sha256(buffer) }
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
console.log(`[seed:tse] downloading ${url}`)
const buffer = await downloadToBuffer(url)
const hash = sha256(buffer)
await writeFile(path, buffer)
console.log(`[seed:tse] saved ${path} (${buffer.length} bytes, sha256=${hash})`)
return { key, url, buffer, hash }
}
const loadCsvFromZip = async (zipBuffer, fileName) => {
const entry = await readZipEntry(zipBuffer, (name) => name.endsWith(fileName))
if (!entry) die(`ZIP entry not found: ${fileName}`)
console.log(`[seed:tse] parsing ${entry.fileName} (${entry.buffer.length} bytes)`)
return parseTseCsvBuffer(entry.buffer)
}
const main = async () => {
assertLocalDatabase(
'seed:tse',
'This seed WRITES election reference data and is meant for the local Postgres.\n' +
' 1. pnpm db:start\n' +
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local',
)
const [votacaoZip, detalheZip, candZip] = await Promise.all(SOURCES.map(ensureCachedZip))
console.log('[seed:tse] provenance:')
for (const source of [votacaoZip, detalheZip, candZip]) {
console.log(` - ${source.url}`)
console.log(` sha256=${source.hash}`)
}
// Sequential: BA vote CSV alone is hundreds of MB; parallel parse spikes peak RAM.
const voteBa = await loadCsvFromZip(votacaoZip.buffer, 'votacao_candidato_munzona_2022_BA.csv')
const voteBr = await loadCsvFromZip(votacaoZip.buffer, 'votacao_candidato_munzona_2022_BR.csv')
const detalheBa = await loadCsvFromZip(
detalheZip.buffer,
'detalhe_votacao_munzona_2022_BA.csv',
)
const detalheBr = await loadCsvFromZip(
detalheZip.buffer,
'detalhe_votacao_munzona_2022_BR.csv',
)
const candBa = await loadCsvFromZip(candZip.buffer, 'consulta_cand_2022_BA.csv')
const candBr = await loadCsvFromZip(candZip.buffer, 'consulta_cand_2022_BR.csv')
const built = buildElectionResultsFromCsvRows({
voteRows: [...voteBa, ...voteBr],
detalheRows: [...detalheBa, ...detalheBr],
candBaRows: candBa,
candBrRows: candBr,
})
if (built.unknownMunicipalities.length > 0) {
die(
`Municípios TSE sem mapeamento canônico (${built.unknownMunicipalities.length}):\n - ` +
built.unknownMunicipalities.slice(0, 20).join('\n - ') +
(built.unknownMunicipalities.length > 20 ? '\n - …' : ''),
)
}
console.log(
`[seed:tse] parsed votes=${built.votes.length} tallies=${built.tallies.length} candidates=${built.candidates.length}`,
)
const bundles = buildImportBundles(built)
console.log(`[seed:tse] importing ${bundles.length} scopes…`)
const payload = await getPayload({ config })
const counts = await importElectionBundles(payload, bundles)
let votes = 0
let tallies = 0
let candidates = 0
for (const count of counts) {
votes += count.votesInserted
tallies += count.talliesInserted
candidates += count.candidatesInserted
console.log(
` ${count.scope.office} turn=${count.scope.turn}: votes=${count.votesInserted} tallies=${count.talliesInserted} candidates=${count.candidatesInserted}`,
)
}
console.log(
`\n[seed:tse] done. inserted votes=${votes} tallies=${tallies} candidates=${candidates}`,
)
process.exit(0)
}
main().catch((error) => {
console.error('[seed:tse] fatal:', error)
process.exit(1)
})

View file

@ -0,0 +1,159 @@
import type { CollectionConfig } from 'payload'
import { canMutateElectionData, canReadElectionData } from '@/utilities/campaignAccess'
import {
ELECTION_ELECTED_BY_OPTIONS,
ELECTION_OFFICE_OPTIONS,
ELECTION_RUNNING_AGAIN_OPTIONS,
ELECTION_TURN_OPTIONS,
} from '@/lib/electionResults'
const slug = 'electionCandidate'
export const ElectionCandidate: CollectionConfig<typeof slug> = {
slug,
labels: {
singular: 'Candidato eleitoral',
plural: 'Candidatos eleitorais',
},
admin: {
group: 'Dados Eleitorais',
useAsTitle: 'urnaName',
defaultColumns: [
'year',
'office',
'candidateNumber',
'urnaName',
'party',
'elected',
'updatedAt',
],
description: 'Registro de candidatura TSE (dado público).',
},
access: {
create: canMutateElectionData,
read: canReadElectionData,
update: canMutateElectionData,
delete: canMutateElectionData,
},
indexes: [
{
fields: ['year', 'office', 'turn', 'state', 'candidateNumber'],
unique: true,
},
],
fields: [
{
name: 'year',
type: 'number',
label: 'Ano',
required: true,
index: true,
min: 2000,
max: 2100,
},
{
name: 'office',
type: 'select',
label: 'Cargo',
required: true,
index: true,
options: [...ELECTION_OFFICE_OPTIONS],
},
{
name: 'turn',
type: 'select',
label: 'Turno',
required: true,
index: true,
options: [...ELECTION_TURN_OPTIONS],
},
{
name: 'state',
type: 'text',
label: 'UF',
required: true,
defaultValue: 'BA',
maxLength: 2,
index: true,
},
{
name: 'candidateNumber',
type: 'number',
label: 'Número do candidato',
required: true,
index: true,
min: 0,
},
{
name: 'urnaName',
type: 'text',
label: 'Nome na urna',
required: true,
maxLength: 160,
},
{
name: 'completeName',
type: 'text',
label: 'Nome completo',
maxLength: 200,
},
{
name: 'party',
type: 'text',
label: 'Partido',
maxLength: 40,
},
{
name: 'coalition',
type: 'text',
label: 'Coligação',
maxLength: 240,
},
{
name: 'candidateStatus',
type: 'text',
label: 'Situação da candidatura',
maxLength: 80,
},
{
name: 'elected',
type: 'checkbox',
label: 'Eleito',
defaultValue: false,
index: true,
},
{
name: 'electedBy',
type: 'select',
label: 'Forma de eleição',
options: [...ELECTION_ELECTED_BY_OPTIONS],
},
{
name: 'totalVotesState',
type: 'number',
label: 'Total de votos no estado',
min: 0,
},
{
name: 'identityKey',
type: 'text',
label: 'Chave de identidade cross-ano',
index: true,
maxLength: 64,
admin: {
description: 'sha256(nome urna + naturalidade + partido); só dados públicos.',
readOnly: true,
},
},
{
name: 'runningAgain2026',
type: 'select',
label: 'Concorre de novo em 2026',
required: true,
defaultValue: 'desconhecido',
options: [...ELECTION_RUNNING_AGAIN_OPTIONS],
},
],
}

View file

@ -0,0 +1,158 @@
import type { CollectionConfig } from 'payload'
import { canMutateElectionData, canReadElectionData } from '@/utilities/campaignAccess'
import {
ELECTION_OFFICE_OPTIONS,
ELECTION_TURN_OPTIONS,
ELECTION_VOTE_TYPE_OPTIONS,
} from '@/lib/electionResults'
const slug = 'electionCandidateVote'
export const ElectionCandidateVote: CollectionConfig<typeof slug> = {
slug,
labels: {
singular: 'Voto por candidato',
plural: 'Votos por candidato',
},
admin: {
group: 'Dados Eleitorais',
useAsTitle: 'candidateName',
defaultColumns: [
'year',
'office',
'cityName',
'zoneNumber',
'candidateNumber',
'votes',
'updatedAt',
],
description: 'Votos nominais oficiais TSE por município e zona (dado público).',
},
access: {
create: canMutateElectionData,
read: canReadElectionData,
update: canMutateElectionData,
delete: canMutateElectionData,
},
indexes: [
{
fields: [
'year',
'office',
'turn',
'state',
'cityCode',
'zoneNumber',
'candidateNumber',
'voteType',
],
unique: true,
},
],
fields: [
{
name: 'year',
type: 'number',
label: 'Ano',
required: true,
index: true,
min: 2000,
max: 2100,
},
{
name: 'office',
type: 'select',
label: 'Cargo',
required: true,
index: true,
options: [...ELECTION_OFFICE_OPTIONS],
},
{
name: 'turn',
type: 'select',
label: 'Turno',
required: true,
index: true,
options: [...ELECTION_TURN_OPTIONS],
},
{
name: 'state',
type: 'text',
label: 'UF',
required: true,
defaultValue: 'BA',
maxLength: 2,
index: true,
},
{
name: 'cityCode',
type: 'text',
label: 'Código do município (TSE)',
required: true,
index: true,
maxLength: 16,
},
{
name: 'cityName',
type: 'text',
label: 'Município',
required: true,
index: true,
maxLength: 120,
},
{
name: 'zoneNumber',
type: 'number',
label: 'Zona TSE',
required: true,
index: true,
min: 1,
max: 999,
},
{
name: 'candidateNumber',
type: 'number',
label: 'Número do candidato',
required: true,
index: true,
min: 0,
},
{
name: 'candidateName',
type: 'text',
label: 'Nome na urna',
required: true,
maxLength: 160,
},
{
name: 'coalition',
type: 'text',
label: 'Coligação',
maxLength: 240,
},
{
name: 'party',
type: 'text',
label: 'Partido',
maxLength: 40,
},
{
name: 'voteType',
type: 'select',
label: 'Tipo de voto',
required: true,
index: true,
defaultValue: 'nominal',
options: [...ELECTION_VOTE_TYPE_OPTIONS],
},
{
name: 'votes',
type: 'number',
label: 'Votos',
required: true,
min: 1,
},
],
}

View file

@ -0,0 +1,188 @@
import type { CollectionConfig } from 'payload'
import { canMutateElectionData, canReadElectionData } from '@/utilities/campaignAccess'
import { ELECTION_OFFICE_OPTIONS, ELECTION_TURN_OPTIONS } from '@/lib/electionResults'
const slug = 'electionTally'
export const ElectionTally: CollectionConfig<typeof slug> = {
slug,
labels: {
singular: 'Apuração eleitoral',
plural: 'Apurações eleitorais',
},
admin: {
group: 'Dados Eleitorais',
useAsTitle: 'cityName',
defaultColumns: ['year', 'office', 'turn', 'cityName', 'zoneNumber', 'updatedAt'],
description: 'Totais oficiais TSE por município e zona (dado público).',
},
access: {
create: canMutateElectionData,
read: canReadElectionData,
update: canMutateElectionData,
delete: canMutateElectionData,
},
indexes: [
{
fields: ['year', 'office', 'turn', 'state', 'cityCode', 'zoneNumber'],
unique: true,
},
],
fields: [
{
name: 'year',
type: 'number',
label: 'Ano',
required: true,
index: true,
min: 2000,
max: 2100,
},
{
name: 'office',
type: 'select',
label: 'Cargo',
required: true,
index: true,
options: [...ELECTION_OFFICE_OPTIONS],
},
{
name: 'turn',
type: 'select',
label: 'Turno',
required: true,
index: true,
options: [...ELECTION_TURN_OPTIONS],
},
{
name: 'state',
type: 'text',
label: 'UF',
required: true,
defaultValue: 'BA',
maxLength: 2,
index: true,
},
{
name: 'cityCode',
type: 'text',
label: 'Código do município (TSE)',
required: true,
index: true,
maxLength: 16,
},
{
name: 'cityName',
type: 'text',
label: 'Município',
required: true,
index: true,
maxLength: 120,
},
{
name: 'zoneNumber',
type: 'number',
label: 'Zona TSE',
required: true,
index: true,
min: 1,
max: 999,
},
{
name: 'aptos',
type: 'number',
label: 'Eleitores aptos',
required: true,
min: 0,
},
{
name: 'comparecimento',
type: 'number',
label: 'Comparecimento',
required: true,
min: 0,
},
{
name: 'abstencoes',
type: 'number',
label: 'Abstenções',
required: true,
min: 0,
},
{
name: 'votosValidos',
type: 'number',
label: 'Votos válidos',
required: true,
min: 0,
},
{
name: 'votosNominaisValidos',
type: 'number',
label: 'Votos nominais válidos',
required: true,
min: 0,
},
{
name: 'votosLegenda',
type: 'number',
label: 'Votos de legenda',
required: true,
min: 0,
},
{
name: 'votosBranco',
type: 'number',
label: 'Votos em branco',
required: true,
min: 0,
},
{
name: 'votosNulo',
type: 'number',
label: 'Votos nulos',
required: true,
min: 0,
},
{
name: 'votosAnulados',
type: 'number',
label: 'Votos anulados',
required: true,
min: 0,
defaultValue: 0,
},
{
name: 'winnerCandidateNumber',
type: 'number',
label: 'Número do mais votado',
min: 0,
},
{
name: 'winnerCandidateName',
type: 'text',
label: 'Nome do mais votado',
maxLength: 160,
},
{
name: 'winnerVotes',
type: 'number',
label: 'Votos do mais votado',
min: 0,
},
{
name: 'winnerCoalition',
type: 'text',
label: 'Coligação do mais votado',
maxLength: 240,
},
{
name: 'winnerParty',
type: 'text',
label: 'Partido do mais votado',
maxLength: 40,
},
],
}

View file

@ -0,0 +1,30 @@
import { createHash } from 'node:crypto'
import { normalizeMunicipalityKey } from '@/lib/electionResults'
/** Same NFD/case fold as municipality keys; empty string for missing parts. */
export const normalizeIdentityPart = (value: string | null | undefined): string =>
normalizeMunicipalityKey(value ?? '')
export type IdentityKeyInput = {
urnaName: string | null | undefined
birthCity: string | null | undefined
birthState: string | null | undefined
party: string | null | undefined
}
/**
* identityKey = sha256(normalize(urnaName) + birthCity + birthState + party)
* Collisions (homonyms) and false negatives (party change) are expected;
* admin review sets runningAgain2026 before relying on the match.
*/
export const computeIdentityKey = (input: IdentityKeyInput): string => {
const material = [
normalizeIdentityPart(input.urnaName),
normalizeIdentityPart(input.birthCity),
normalizeIdentityPart(input.birthState),
normalizeIdentityPart(input.party),
].join('|')
return createHash('sha256').update(material, 'utf8').digest('hex')
}

317
src/lib/electionResults.ts Normal file
View file

@ -0,0 +1,317 @@
import { bahiaMunicipalities } from '@/lib/bahiaTerritories'
export const ELECTION_YEAR_2022 = 2022 as const
export const ELECTION_OFFICE_OPTIONS = [
{ label: 'Presidente', value: 'presidente' },
{ label: 'Governador', value: 'governador' },
{ label: 'Deputado federal', value: 'deputado_federal' },
{ label: 'Deputado estadual', value: 'deputado_estadual' },
] as const
export const ELECTION_OFFICES = ELECTION_OFFICE_OPTIONS.map((o) => o.value)
export type ElectionOffice = (typeof ELECTION_OFFICES)[number]
export const ELECTION_TURN_OPTIONS = [
{ label: '1º turno', value: '1' },
{ label: '2º turno', value: '2' },
] as const
export const ELECTION_TURNS = ELECTION_TURN_OPTIONS.map((o) => o.value)
export type ElectionTurn = (typeof ELECTION_TURNS)[number]
export const ELECTION_VOTE_TYPE_OPTIONS = [
{ label: 'Nominal', value: 'nominal' },
{ label: 'Legenda', value: 'legenda' },
] as const
export const ELECTION_VOTE_TYPES = ELECTION_VOTE_TYPE_OPTIONS.map((o) => o.value)
export type ElectionVoteType = (typeof ELECTION_VOTE_TYPES)[number]
export const ELECTION_ELECTED_BY_OPTIONS = [
{ label: 'Quociente partidário', value: 'QP' },
{ label: 'Média', value: 'média' },
{ label: '2º turno', value: '2º turno' },
] as const
export const ELECTION_ELECTED_BY = ELECTION_ELECTED_BY_OPTIONS.map((o) => o.value)
export type ElectionElectedBy = (typeof ELECTION_ELECTED_BY)[number]
export const ELECTION_RUNNING_AGAIN_OPTIONS = [
{ label: 'Sim', value: 'sim' },
{ label: 'Não', value: 'nao' },
{ label: 'Desconhecido', value: 'desconhecido' },
] as const
export const ELECTION_RUNNING_AGAIN = ELECTION_RUNNING_AGAIN_OPTIONS.map((o) => o.value)
export type ElectionRunningAgain = (typeof ELECTION_RUNNING_AGAIN)[number]
/** Solla — dep. federal BA, PT, nº 1313 (eleito em 2022). */
export const SOLLA_CANDIDATE_NUMBER = 1313
export const LULA_CANDIDATE_NUMBER = 13
export const JERONIMO_CANDIDATE_NUMBER = 13
/** TSE CD_CARGO → office enum used in collections. */
export const TSE_CARGO_TO_OFFICE: Readonly<Record<string, ElectionOffice>> = {
'1': 'presidente',
'3': 'governador',
'6': 'deputado_federal',
'7': 'deputado_estadual',
}
export type ParsedElectedStatus = {
elected: boolean
electedBy: ElectionElectedBy | null
}
/**
* Map TSE DS_SIT_TOT_TURNO to elected flag + electedBy.
* Values observed in 2022 consulta_cand / votacao datasets.
* When `turn` is `2` and the candidate is elected, electedBy defaults to `2º turno`
* (TSE often writes plain "ELEITO" on the second-turn file).
*/
export const parseElectedStatus = (
dsSitTotTurno: string | null | undefined,
turn?: ElectionTurn | null,
): ParsedElectedStatus => {
const value = (dsSitTotTurno ?? '').trim().toUpperCase()
if (!value || value === '#NULO#' || value === 'NÃO ELEITO' || value === 'NAO ELEITO') {
return { elected: false, electedBy: null }
}
if (value.includes('2º TURNO') || value.includes('2O TURNO') || value.includes('2° TURNO')) {
// "ELEITO … 2º TURNO" counts as elected; bare "2º TURNO" means advanced only.
if (value.startsWith('ELEITO')) {
return { elected: true, electedBy: '2º turno' }
}
return { elected: false, electedBy: null }
}
if (value.includes('MÉDIA') || value.includes('MEDIA')) {
return { elected: true, electedBy: 'média' }
}
if (value.startsWith('ELEITO')) {
return { elected: true, electedBy: turn === '2' ? '2º turno' : 'QP' }
}
return { elected: false, electedBy: null }
}
/** Fold accents and case for municipality matching. */
export const normalizeMunicipalityKey = (value: string): string =>
value
.normalize('NFD')
.replace(/\p{M}/gu, '')
.replace(/['’`]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toUpperCase()
const bahiaMunicipalityByKey = new Map(
bahiaMunicipalities.map((name) => [normalizeMunicipalityKey(name), name]),
)
/**
* Known TSE NM_MUNICIPIO spellings that diverge from the SEPLAN/SECULT canonical
* names in bahiaTerritories (2022 open-data files). Keys are normalizeMunicipalityKey outputs.
*/
const TSE_MUNICIPALITY_ALIASES: Readonly<Record<string, string>> = {
// TSE: CAMACÃ → canonical Camacan
CAMACA: 'Camacan',
// TSE: "DIAS D ÁVILA" (space, no apostrophe) → Dias d'Ávila
'DIAS D AVILA': "Dias d'Ávila",
// TSE: MUQUÉM DO SÃO FRANCISCO → Muquém de São Francisco
'MUQUEM DO SAO FRANCISCO': 'Muquém de São Francisco',
// TSE: SANTA TEREZINHA → Santa Teresinha
'SANTA TEREZINHA': 'Santa Teresinha',
}
for (const [aliasKey, canonical] of Object.entries(TSE_MUNICIPALITY_ALIASES)) {
if (!bahiaMunicipalityByKey.has(normalizeMunicipalityKey(canonical))) {
throw new Error(`TSE alias points to unknown canonical municipality: ${canonical}`)
}
bahiaMunicipalityByKey.set(aliasKey, canonical)
}
export class UnknownMunicipalityError extends Error {
constructor(public readonly tseName: string) {
super(`Município TSE sem mapeamento canônico: "${tseName}"`)
this.name = 'UnknownMunicipalityError'
}
}
/** Cache raw TSE NM_MUNICIPIO → canonical (millions of repeated lookups on seed). */
const municipalityResolveCache = new Map<string, string>()
/**
* Map TSE NM_MUNICIPIO (usually ALL CAPS) to the canonical Bahia municipality name
* used by bahiaTerritories / electoralNucleus.city. Fails closed on unknown names.
*/
export const canonicalizeMunicipalityName = (tseMunicipalityName: string): string => {
const cached = municipalityResolveCache.get(tseMunicipalityName)
if (cached) return cached
const key = normalizeMunicipalityKey(tseMunicipalityName)
const canonical = bahiaMunicipalityByKey.get(key)
if (!canonical) {
throw new UnknownMunicipalityError(tseMunicipalityName)
}
municipalityResolveCache.set(tseMunicipalityName, canonical)
return canonical
}
export const isElectionTurn = (value: string): value is ElectionTurn =>
(ELECTION_TURNS as readonly string[]).includes(value)
export type CandidateVoteRow = {
year: number
office: ElectionOffice
turn: ElectionTurn
state: string
cityCode: string
cityName: string
zoneNumber: number
candidateNumber: number
candidateName: string
coalition: string | null
party: string | null
voteType: ElectionVoteType
votes: number
}
/** Winner fields as stored on electionTally (null when no nominal votes in that cell). */
export type TallyWinner = {
winnerCandidateNumber: number | null
winnerCandidateName: string | null
winnerVotes: number | null
winnerCoalition: string | null
winnerParty: string | null
}
type ResolvedWinner = {
winnerCandidateNumber: number
winnerCandidateName: string
winnerVotes: number
winnerCoalition: string | null
winnerParty: string | null
}
const EMPTY_WINNER: TallyWinner = {
winnerCandidateNumber: null,
winnerCandidateName: null,
winnerVotes: null,
winnerCoalition: null,
winnerParty: null,
}
export const winnerKey = (cityCode: string, zoneNumber: number, office: ElectionOffice, turn: ElectionTurn) =>
`${cityCode}|${zoneNumber}|${office}|${turn}`
/**
* Compute the local winner (highest nominal votes) for each city+zone+office+turn.
* Ties: first seen candidate with the max vote count wins (stable for import order).
*/
export const computeWinnersByScope = (
votes: readonly CandidateVoteRow[],
): Map<string, ResolvedWinner> => {
const winners = new Map<string, ResolvedWinner>()
for (const row of votes) {
if (row.voteType !== 'nominal' || row.votes <= 0) continue
const key = winnerKey(row.cityCode, row.zoneNumber, row.office, row.turn)
const current = winners.get(key)
if (!current || current.winnerVotes < row.votes) {
winners.set(key, {
winnerCandidateNumber: row.candidateNumber,
winnerCandidateName: row.candidateName,
winnerVotes: row.votes,
winnerCoalition: row.coalition,
winnerParty: row.party,
})
}
}
return winners
}
export type TseDetalheApuracaoRow = {
year: number
office: ElectionOffice
turn: ElectionTurn
state: string
cityCode: string
cityName: string
zoneNumber: number
aptos: number
comparecimento: number
abstencoes: number
votosValidos: number
votosNominaisValidos: number
votosLegenda: number
votosBranco: number
votosNulo: number
votosAnulados: number
}
export type ElectionTallyRow = TseDetalheApuracaoRow & TallyWinner
export type ElectionCandidateRow = {
year: number
office: ElectionOffice
turn: ElectionTurn
state: string
candidateNumber: number
urnaName: string
completeName: string | null
party: string | null
coalition: string | null
candidateStatus: string | null
elected: boolean
electedBy: ElectionElectedBy | null
totalVotesState: number
identityKey: string
runningAgain2026: ElectionRunningAgain
}
export const mergeTallyWithWinners = (
tallies: readonly TseDetalheApuracaoRow[],
winners: ReadonlyMap<string, ResolvedWinner>,
): ElectionTallyRow[] =>
tallies.map((tally) => {
const winner =
winners.get(winnerKey(tally.cityCode, tally.zoneNumber, tally.office, tally.turn)) ??
EMPTY_WINNER
return { ...tally, ...winner }
})
/** Parse a TSE integer cell that may be "#NULO#" / empty. */
export const parseTseInt = (value: string | undefined | null, fallback = 0): number => {
if (value == null) return fallback
const trimmed = value.trim()
if (!trimmed || trimmed === '#NULO#' || trimmed === '#NE#') return fallback
const n = Number(trimmed)
return Number.isFinite(n) ? n : fallback
}
export const parseTseOptionalText = (value: string | undefined | null): string | null => {
if (value == null) return null
const trimmed = value.trim()
if (!trimmed || trimmed === '#NULO#' || trimmed === '#NE#') return null
return trimmed
}
export const officeFromTseCargo = (cdCargo: string): ElectionOffice | null => {
const office = TSE_CARGO_TO_OFFICE[cdCargo.trim()]
return office ?? null
}
export const turnFromTse = (nrTurno: string): ElectionTurn | null => {
const turn = nrTurno.trim()
return isElectionTurn(turn) ? turn : null
}
export const assertAllCanonicalMunicipalitiesResolvable = (): void => {
for (const name of bahiaMunicipalities) {
const resolved = canonicalizeMunicipalityName(name)
if (resolved !== name) {
throw new Error(`canonicalizeMunicipalityName drifted for "${name}" → "${resolved}"`)
}
}
}

View file

@ -0,0 +1,98 @@
import type {
CandidateVoteRow,
ElectionCandidateRow,
ElectionOffice,
TseDetalheApuracaoRow,
} from '@/lib/electionResults'
import { ELECTION_OFFICES, ELECTION_YEAR_2022, UnknownMunicipalityError } from '@/lib/electionResults'
import {
applyStateVoteTotals,
dedupeCandidates,
parseCandidateVoteRow,
parseConsultaCandRow,
parseDetalheApuracaoRow,
type TseCsvRow,
} from '@/lib/electionResultsParse'
const ALL_OFFICES = new Set<ElectionOffice>(ELECTION_OFFICES)
const STATE_OFFICES = new Set<ElectionOffice>([
'governador',
'deputado_federal',
'deputado_estadual',
])
const PRESIDENT_OFFICE = new Set<ElectionOffice>(['presidente'])
export type BuiltElectionResults = {
votes: CandidateVoteRow[]
tallies: TseDetalheApuracaoRow[]
candidates: ElectionCandidateRow[]
unknownMunicipalities: string[]
}
const collectParsed = <T extends { year: number }>(
rows: readonly TseCsvRow[],
year: number,
unknownMunicipalities: Set<string>,
parseRow: (row: TseCsvRow) => T | null,
): T[] => {
const out: T[] = []
for (const row of rows) {
try {
const parsed = parseRow(row)
if (parsed && parsed.year === year) out.push(parsed)
} catch (error) {
if (error instanceof UnknownMunicipalityError) {
unknownMunicipalities.add(error.tseName)
continue
}
throw error
}
}
return out
}
/**
* Build typed election rows from already-parsed TSE CSV tables.
* Safe for fixtures and for the full seed (no I/O).
*/
export const buildElectionResultsFromCsvRows = (args: {
voteRows: readonly TseCsvRow[]
detalheRows: readonly TseCsvRow[]
candBaRows: readonly TseCsvRow[]
candBrRows: readonly TseCsvRow[]
year?: number
}): BuiltElectionResults => {
const year = args.year ?? ELECTION_YEAR_2022
const unknownMunicipalities = new Set<string>()
const votes = collectParsed(args.voteRows, year, unknownMunicipalities, (row) =>
parseCandidateVoteRow(row, { stateFilter: 'BA', offices: ALL_OFFICES }),
)
const tallies = collectParsed(args.detalheRows, year, unknownMunicipalities, (row) =>
parseDetalheApuracaoRow(row, { stateFilter: 'BA', offices: ALL_OFFICES }),
)
const candidatesRaw: ElectionCandidateRow[] = []
for (const row of args.candBaRows) {
const parsed = parseConsultaCandRow(row, {
stateFilter: 'BA',
offices: STATE_OFFICES,
})
if (parsed && parsed.year === year) candidatesRaw.push(parsed)
}
for (const row of args.candBrRows) {
const parsed = parseConsultaCandRow(row, {
stateFilter: 'BR',
offices: PRESIDENT_OFFICE,
forceState: 'BA',
})
if (parsed && parsed.year === year) candidatesRaw.push(parsed)
}
return {
votes,
tallies,
candidates: applyStateVoteTotals(dedupeCandidates(candidatesRaw), votes),
unknownMunicipalities: [...unknownMunicipalities].sort(),
}
}

View file

@ -0,0 +1,22 @@
import { parse } from 'csv-parse/sync'
import type { TseCsvRow } from '@/lib/electionResultsParse'
/**
* Parse a TSE open-data CSV buffer (Latin-1 / ISO-8859-1, `;` separator, quoted fields).
*/
export const parseTseCsvBuffer = (buffer: Buffer): TseCsvRow[] => {
const text = buffer.toString('latin1')
return parse(text, {
columns: true,
delimiter: ';',
relax_quotes: true,
relax_column_count: true,
skip_empty_lines: true,
trim: true,
bom: true,
}) as TseCsvRow[]
}
export const parseTseCsvString = (text: string): TseCsvRow[] =>
parseTseCsvBuffer(Buffer.from(text, 'utf8'))

View file

@ -0,0 +1,220 @@
/** Pure parsers for TSE open-data CSVs (Latin-1, `;`-separated). */
import {
canonicalizeMunicipalityName,
officeFromTseCargo,
parseElectedStatus,
parseTseInt,
parseTseOptionalText,
turnFromTse,
type CandidateVoteRow,
type ElectionCandidateRow,
type ElectionOffice,
type ElectionTurn,
type TseDetalheApuracaoRow,
} from '@/lib/electionResults'
import { computeIdentityKey } from '@/lib/electionCandidateIdentity'
export type TseCsvRow = Record<string, string>
const cell = (row: TseCsvRow, key: string): string => row[key] ?? ''
type ScopeParseOptions = {
/** Uppercase UF (e.g. BA). Compared as-is against SG_UF — normalize at the call site. */
stateFilter?: string
offices?: ReadonlySet<ElectionOffice>
}
const matchScope = (
row: TseCsvRow,
options: ScopeParseOptions,
): { state: string; office: ElectionOffice; turn: ElectionTurn } | null => {
const state = cell(row, 'SG_UF').trim().toUpperCase()
if (options.stateFilter && state !== options.stateFilter) return null
const office = officeFromTseCargo(cell(row, 'CD_CARGO'))
if (!office) return null
if (options.offices && !options.offices.has(office)) return null
const turn = turnFromTse(cell(row, 'NR_TURNO'))
if (!turn) return null
return { state, office, turn }
}
export type ParseVoteOptions = ScopeParseOptions & {
/** Skip zero-vote rows (default true — collection requires votes >= 1). */
skipZeroVotes?: boolean
}
/**
* Parse a votacao_candidato_munzona row into a CandidateVoteRow.
* Returns null when the row is out of scope (wrong UF/cargo) or has zero votes.
*/
export const parseCandidateVoteRow = (
row: TseCsvRow,
options: ParseVoteOptions = {},
): CandidateVoteRow | null => {
const scope = matchScope(row, options)
if (!scope) return null
const votes = parseTseInt(cell(row, 'QT_VOTOS_NOMINAIS'))
if ((options.skipZeroVotes ?? true) && votes <= 0) return null
return {
year: parseTseInt(cell(row, 'ANO_ELEICAO')),
office: scope.office,
turn: scope.turn,
state: scope.state,
cityCode: cell(row, 'CD_MUNICIPIO').trim(),
cityName: canonicalizeMunicipalityName(cell(row, 'NM_MUNICIPIO')),
zoneNumber: parseTseInt(cell(row, 'NR_ZONA')),
candidateNumber: parseTseInt(cell(row, 'NR_CANDIDATO')),
candidateName: parseTseOptionalText(cell(row, 'NM_URNA_CANDIDATO')) ?? '',
coalition: parseTseOptionalText(cell(row, 'NM_COLIGACAO')),
party: parseTseOptionalText(cell(row, 'SG_PARTIDO')),
voteType: 'nominal',
votes,
}
}
export type ParseDetalheOptions = ScopeParseOptions
/**
* Parse detalhe_votacao_munzona into a tally row (without winner — filled later).
*/
export const parseDetalheApuracaoRow = (
row: TseCsvRow,
options: ParseDetalheOptions = {},
): TseDetalheApuracaoRow | null => {
const scope = matchScope(row, options)
if (!scope) return null
// Prefer QT_VOTOS_VALIDOS when present; else nominais_validos + legenda.
const votosNominaisValidos = parseTseInt(
cell(row, 'QT_VOTOS_NOMINAIS_VALIDOS') || cell(row, 'QT_VOTOS_NOMINAIS'),
)
const votosLegenda = parseTseInt(cell(row, 'QT_VOTOS_LEGENDA'))
const votosValidos =
parseTseInt(cell(row, 'QT_VOTOS_VALIDOS')) || votosNominaisValidos + votosLegenda
return {
year: parseTseInt(cell(row, 'ANO_ELEICAO')),
office: scope.office,
turn: scope.turn,
state: scope.state,
cityCode: cell(row, 'CD_MUNICIPIO').trim(),
cityName: canonicalizeMunicipalityName(cell(row, 'NM_MUNICIPIO')),
zoneNumber: parseTseInt(cell(row, 'NR_ZONA')),
aptos: parseTseInt(cell(row, 'QT_APTOS')),
comparecimento: parseTseInt(cell(row, 'QT_COMPARECIMENTO')),
abstencoes: parseTseInt(cell(row, 'QT_ABSTENCOES')),
votosValidos,
votosNominaisValidos,
votosLegenda,
votosBranco: parseTseInt(cell(row, 'QT_VOTOS_BRANCOS')),
votosNulo: parseTseInt(cell(row, 'QT_VOTOS_NULOS')),
votosAnulados: parseTseInt(
cell(row, 'QT_VOTOS_ANULADOS') || cell(row, 'QT_VOTOS_ANULADOS_APU_SEP'),
),
}
}
export type ParseCandidateOptions = ScopeParseOptions & {
/** Override state stored (presidente candidacies are national; store BA for our model). */
forceState?: string
}
/**
* Parse consulta_cand into ElectionCandidateRow.
* President candidates live in the BR file (SG_UF=BR); we store state=BA for the BA campaign model.
*/
export const parseConsultaCandRow = (
row: TseCsvRow,
options: ParseCandidateOptions = {},
): ElectionCandidateRow | null => {
const scope = matchScope(row, options)
if (!scope) return null
const urnaName = parseTseOptionalText(cell(row, 'NM_URNA_CANDIDATO')) ?? ''
const party = parseTseOptionalText(cell(row, 'SG_PARTIDO'))
const birthCity = parseTseOptionalText(cell(row, 'NM_MUNICIPIO_NASCIMENTO'))
const birthState = parseTseOptionalText(cell(row, 'SG_UF_NASCIMENTO'))
const { elected, electedBy } = parseElectedStatus(cell(row, 'DS_SIT_TOT_TURNO'), scope.turn)
return {
year: parseTseInt(cell(row, 'ANO_ELEICAO')),
office: scope.office,
turn: scope.turn,
state: (options.forceState ?? scope.state).toUpperCase(),
candidateNumber: parseTseInt(cell(row, 'NR_CANDIDATO')),
urnaName,
completeName: parseTseOptionalText(cell(row, 'NM_CANDIDATO')),
party,
coalition: parseTseOptionalText(cell(row, 'NM_COLIGACAO')),
candidateStatus: parseTseOptionalText(cell(row, 'DS_SITUACAO_CANDIDATURA')),
elected,
electedBy,
totalVotesState: 0,
identityKey: computeIdentityKey({ urnaName, birthCity, birthState, party }),
runningAgain2026: 'desconhecido',
}
}
export type ScopeKey = {
year: number
office: ElectionOffice
turn: ElectionTurn
}
export const scopeKeyString = (scope: ScopeKey): string =>
`${scope.year}|${scope.office}|${scope.turn}`
const candidateAggregateKey = (row: {
year: number
office: ElectionOffice
turn: ElectionTurn
state: string
candidateNumber: number
}): string =>
`${row.year}|${row.office}|${row.turn}|${row.state}|${row.candidateNumber}`
/** Deduplicate candidate rows by unique key, preferring elected=true when colliding. */
export const dedupeCandidates = (rows: readonly ElectionCandidateRow[]): ElectionCandidateRow[] => {
const map = new Map<string, ElectionCandidateRow>()
for (const row of rows) {
const key = candidateAggregateKey(row)
const existing = map.get(key)
if (!existing || (!existing.elected && row.elected)) {
map.set(key, row)
}
}
return [...map.values()]
}
/** Aggregate nominal votes per candidate across all zones → totalVotesState. */
export const applyStateVoteTotals = (
candidates: readonly ElectionCandidateRow[],
votes: readonly CandidateVoteRow[],
): ElectionCandidateRow[] => {
const totals = new Map<string, number>()
for (const vote of votes) {
const key = candidateAggregateKey(vote)
totals.set(key, (totals.get(key) ?? 0) + vote.votes)
}
return candidates.map((candidate) => ({
...candidate,
totalVotesState: totals.get(candidateAggregateKey(candidate)) ?? 0,
}))
}
export const groupByScope = <T extends ScopeKey>(rows: readonly T[]): Map<string, T[]> => {
const map = new Map<string, T[]>()
for (const row of rows) {
const key = scopeKeyString(row)
const list = map.get(key)
if (list) list.push(row)
else map.set(key, [row])
}
return map
}

View file

@ -0,0 +1,69 @@
import { fromBuffer as yauzlFromBuffer } from 'yauzl'
/**
* Read a single entry from a ZIP buffer by exact filename or suffix match.
* Returns null when no matching entry exists.
*/
export const readZipEntry = async (
zipBuffer: Buffer,
matcher: string | ((fileName: string) => boolean),
): Promise<{ fileName: string; buffer: Buffer } | null> => {
const match =
typeof matcher === 'string'
? (name: string) => name === matcher || name.endsWith(`/${matcher}`) || name.endsWith(matcher)
: matcher
return new Promise((resolve, reject) => {
yauzlFromBuffer(zipBuffer, { lazyEntries: true }, (err, zipfile) => {
if (err || !zipfile) {
reject(err ?? new Error('Falha ao abrir ZIP'))
return
}
let settled = false
const finish = (value: { fileName: string; buffer: Buffer } | null) => {
if (settled) return
settled = true
try {
zipfile.close()
} catch {
// ignore close errors after resolve
}
resolve(value)
}
zipfile.readEntry()
zipfile.on('entry', (entry) => {
if (settled) return
if (/\/$/.test(entry.fileName) || !match(entry.fileName)) {
zipfile.readEntry()
return
}
zipfile.openReadStream(entry, (streamErr, readStream) => {
if (streamErr || !readStream) {
reject(streamErr ?? new Error(`Falha ao ler ${entry.fileName}`))
return
}
const chunks: Buffer[] = []
readStream.on('data', (chunk: Buffer) => chunks.push(chunk))
readStream.on('error', reject)
readStream.on('end', () => {
finish({ fileName: entry.fileName, buffer: Buffer.concat(chunks) })
})
})
})
zipfile.on('end', () => {
finish(null)
})
zipfile.on('error', reject)
})
})
}
export const downloadToBuffer = async (url: string): Promise<Buffer> => {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Download falhou (${response.status}): ${url}`)
}
return Buffer.from(await response.arrayBuffer())
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,153 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_election_tally_office" AS ENUM('presidente', 'governador', 'deputado_federal', 'deputado_estadual');
CREATE TYPE "public"."enum_election_tally_turn" AS ENUM('1', '2');
CREATE TYPE "public"."enum_election_candidate_vote_office" AS ENUM('presidente', 'governador', 'deputado_federal', 'deputado_estadual');
CREATE TYPE "public"."enum_election_candidate_vote_turn" AS ENUM('1', '2');
CREATE TYPE "public"."enum_election_candidate_vote_vote_type" AS ENUM('nominal', 'legenda');
CREATE TYPE "public"."enum_election_candidate_office" AS ENUM('presidente', 'governador', 'deputado_federal', 'deputado_estadual');
CREATE TYPE "public"."enum_election_candidate_turn" AS ENUM('1', '2');
CREATE TYPE "public"."enum_election_candidate_elected_by" AS ENUM('QP', 'média', '2º turno');
CREATE TYPE "public"."enum_election_candidate_running_again2026" AS ENUM('sim', 'nao', 'desconhecido');
CREATE TABLE "election_tally" (
"id" serial PRIMARY KEY NOT NULL,
"year" numeric NOT NULL,
"office" "enum_election_tally_office" NOT NULL,
"turn" "enum_election_tally_turn" NOT NULL,
"state" varchar DEFAULT 'BA' NOT NULL,
"city_code" varchar NOT NULL,
"city_name" varchar NOT NULL,
"zone_number" numeric NOT NULL,
"aptos" numeric NOT NULL,
"comparecimento" numeric NOT NULL,
"abstencoes" numeric NOT NULL,
"votos_validos" numeric NOT NULL,
"votos_nominais_validos" numeric NOT NULL,
"votos_legenda" numeric NOT NULL,
"votos_branco" numeric NOT NULL,
"votos_nulo" numeric NOT NULL,
"votos_anulados" numeric DEFAULT 0 NOT NULL,
"winner_candidate_number" numeric,
"winner_candidate_name" varchar,
"winner_votes" numeric,
"winner_coalition" varchar,
"winner_party" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "election_candidate_vote" (
"id" serial PRIMARY KEY NOT NULL,
"year" numeric NOT NULL,
"office" "enum_election_candidate_vote_office" NOT NULL,
"turn" "enum_election_candidate_vote_turn" NOT NULL,
"state" varchar DEFAULT 'BA' NOT NULL,
"city_code" varchar NOT NULL,
"city_name" varchar NOT NULL,
"zone_number" numeric NOT NULL,
"candidate_number" numeric NOT NULL,
"candidate_name" varchar NOT NULL,
"coalition" varchar,
"party" varchar,
"vote_type" "enum_election_candidate_vote_vote_type" DEFAULT 'nominal' NOT NULL,
"votes" numeric NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "election_candidate" (
"id" serial PRIMARY KEY NOT NULL,
"year" numeric NOT NULL,
"office" "enum_election_candidate_office" NOT NULL,
"turn" "enum_election_candidate_turn" NOT NULL,
"state" varchar DEFAULT 'BA' NOT NULL,
"candidate_number" numeric NOT NULL,
"urna_name" varchar NOT NULL,
"complete_name" varchar,
"party" varchar,
"coalition" varchar,
"candidate_status" varchar,
"elected" boolean DEFAULT false,
"elected_by" "enum_election_candidate_elected_by",
"total_votes_state" numeric,
"identity_key" varchar,
"running_again2026" "enum_election_candidate_running_again2026" DEFAULT 'desconhecido' 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 "election_tally_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "election_candidate_vote_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "election_candidate_id" integer;
CREATE INDEX "election_tally_year_idx" ON "election_tally" USING btree ("year");
CREATE INDEX "election_tally_office_idx" ON "election_tally" USING btree ("office");
CREATE INDEX "election_tally_turn_idx" ON "election_tally" USING btree ("turn");
CREATE INDEX "election_tally_state_idx" ON "election_tally" USING btree ("state");
CREATE INDEX "election_tally_city_code_idx" ON "election_tally" USING btree ("city_code");
CREATE INDEX "election_tally_city_name_idx" ON "election_tally" USING btree ("city_name");
CREATE INDEX "election_tally_zone_number_idx" ON "election_tally" USING btree ("zone_number");
CREATE INDEX "election_tally_updated_at_idx" ON "election_tally" USING btree ("updated_at");
CREATE INDEX "election_tally_created_at_idx" ON "election_tally" USING btree ("created_at");
CREATE UNIQUE INDEX "year_office_turn_state_cityCode_zoneNumber_idx" ON "election_tally" USING btree ("year","office","turn","state","city_code","zone_number");
CREATE INDEX "election_candidate_vote_year_idx" ON "election_candidate_vote" USING btree ("year");
CREATE INDEX "election_candidate_vote_office_idx" ON "election_candidate_vote" USING btree ("office");
CREATE INDEX "election_candidate_vote_turn_idx" ON "election_candidate_vote" USING btree ("turn");
CREATE INDEX "election_candidate_vote_state_idx" ON "election_candidate_vote" USING btree ("state");
CREATE INDEX "election_candidate_vote_city_code_idx" ON "election_candidate_vote" USING btree ("city_code");
CREATE INDEX "election_candidate_vote_city_name_idx" ON "election_candidate_vote" USING btree ("city_name");
CREATE INDEX "election_candidate_vote_zone_number_idx" ON "election_candidate_vote" USING btree ("zone_number");
CREATE INDEX "election_candidate_vote_candidate_number_idx" ON "election_candidate_vote" USING btree ("candidate_number");
CREATE INDEX "election_candidate_vote_vote_type_idx" ON "election_candidate_vote" USING btree ("vote_type");
CREATE INDEX "election_candidate_vote_updated_at_idx" ON "election_candidate_vote" USING btree ("updated_at");
CREATE INDEX "election_candidate_vote_created_at_idx" ON "election_candidate_vote" USING btree ("created_at");
CREATE UNIQUE INDEX "compound_index_idx" ON "election_candidate_vote" USING btree ("year","office","turn","state","city_code","zone_number","candidate_number","vote_type");
CREATE INDEX "election_candidate_year_idx" ON "election_candidate" USING btree ("year");
CREATE INDEX "election_candidate_office_idx" ON "election_candidate" USING btree ("office");
CREATE INDEX "election_candidate_turn_idx" ON "election_candidate" USING btree ("turn");
CREATE INDEX "election_candidate_state_idx" ON "election_candidate" USING btree ("state");
CREATE INDEX "election_candidate_candidate_number_idx" ON "election_candidate" USING btree ("candidate_number");
CREATE INDEX "election_candidate_elected_idx" ON "election_candidate" USING btree ("elected");
CREATE INDEX "election_candidate_identity_key_idx" ON "election_candidate" USING btree ("identity_key");
CREATE INDEX "election_candidate_updated_at_idx" ON "election_candidate" USING btree ("updated_at");
CREATE INDEX "election_candidate_created_at_idx" ON "election_candidate" USING btree ("created_at");
CREATE UNIQUE INDEX "year_office_turn_state_candidateNumber_idx" ON "election_candidate" USING btree ("year","office","turn","state","candidate_number");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_election_tally_fk" FOREIGN KEY ("election_tally_id") REFERENCES "public"."election_tally"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_election_candidate_vote_fk" FOREIGN KEY ("election_candidate_vote_id") REFERENCES "public"."election_candidate_vote"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_election_candidate_fk" FOREIGN KEY ("election_candidate_id") REFERENCES "public"."election_candidate"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_election_tally_id_idx" ON "payload_locked_documents_rels" USING btree ("election_tally_id");
CREATE INDEX "payload_locked_documents_rels_election_candidate_vote_id_idx" ON "payload_locked_documents_rels" USING btree ("election_candidate_vote_id");
CREATE INDEX "payload_locked_documents_rels_election_candidate_id_idx" ON "payload_locked_documents_rels" USING btree ("election_candidate_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "election_tally" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "election_candidate_vote" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "election_candidate" DISABLE ROW LEVEL SECURITY;
DROP TABLE "election_tally" CASCADE;
DROP TABLE "election_candidate_vote" CASCADE;
DROP TABLE "election_candidate" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_election_tally_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_election_candidate_vote_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_election_candidate_fk";
DROP INDEX "payload_locked_documents_rels_election_tally_id_idx";
DROP INDEX "payload_locked_documents_rels_election_candidate_vote_id_idx";
DROP INDEX "payload_locked_documents_rels_election_candidate_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "election_tally_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "election_candidate_vote_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "election_candidate_id";
DROP TYPE "public"."enum_election_tally_office";
DROP TYPE "public"."enum_election_tally_turn";
DROP TYPE "public"."enum_election_candidate_vote_office";
DROP TYPE "public"."enum_election_candidate_vote_turn";
DROP TYPE "public"."enum_election_candidate_vote_vote_type";
DROP TYPE "public"."enum_election_candidate_office";
DROP TYPE "public"."enum_election_candidate_turn";
DROP TYPE "public"."enum_election_candidate_elected_by";
DROP TYPE "public"."enum_election_candidate_running_again2026";`)
}

View file

@ -4,6 +4,7 @@ import * as migration_20260715_181058_add_post_and_tag from './20260715_181058_a
import * as migration_20260715_215834_rename_tag_visible_to_hidden from './20260715_215834_rename_tag_visible_to_hidden';
import * as migration_20260716_010420_add_campaign_user from './20260716_010420_add_campaign_user';
import * as migration_20260718_010733_consolidate_campaign_schema from './20260718_010733_consolidate_campaign_schema';
import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results';
export const migrations = [
{
@ -36,4 +37,10 @@ export const migrations = [
down: migration_20260718_010733_consolidate_campaign_schema.down,
name: '20260718_010733_consolidate_campaign_schema',
},
];
{
up: migration_20260718_195854_add_election_results.up,
down: migration_20260718_195854_add_election_results.down,
name: '20260718_195854_add_election_results',
},
]

View file

@ -74,6 +74,9 @@ export interface Config {
electoralNucleus: ElectoralNucleus;
leadership: Leadership;
nucleusUpdate: NucleusUpdate;
electionTally: ElectionTally;
electionCandidateVote: ElectionCandidateVote;
electionCandidate: ElectionCandidate;
media: Media;
petition: Petition;
contact: Contact;
@ -96,6 +99,9 @@ export interface Config {
electoralNucleus: ElectoralNucleusSelect<false> | ElectoralNucleusSelect<true>;
leadership: LeadershipSelect<false> | LeadershipSelect<true>;
nucleusUpdate: NucleusUpdateSelect<false> | NucleusUpdateSelect<true>;
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
electionCandidateVote: ElectionCandidateVoteSelect<false> | ElectionCandidateVoteSelect<true>;
electionCandidate: ElectionCandidateSelect<false> | ElectionCandidateSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
petition: PetitionSelect<false> | PetitionSelect<true>;
contact: ContactSelect<false> | ContactSelect<true>;
@ -461,6 +467,91 @@ export interface NucleusUpdate {
updatedAt: string;
createdAt: string;
}
/**
* Totais oficiais TSE por município e zona (dado público).
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "electionTally".
*/
export interface ElectionTally {
id: number;
year: number;
office: 'presidente' | 'governador' | 'deputado_federal' | 'deputado_estadual';
turn: '1' | '2';
state: string;
cityCode: string;
cityName: string;
zoneNumber: number;
aptos: number;
comparecimento: number;
abstencoes: number;
votosValidos: number;
votosNominaisValidos: number;
votosLegenda: number;
votosBranco: number;
votosNulo: number;
votosAnulados: number;
winnerCandidateNumber?: number | null;
winnerCandidateName?: string | null;
winnerVotes?: number | null;
winnerCoalition?: string | null;
winnerParty?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* Votos nominais oficiais TSE por município e zona (dado público).
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "electionCandidateVote".
*/
export interface ElectionCandidateVote {
id: number;
year: number;
office: 'presidente' | 'governador' | 'deputado_federal' | 'deputado_estadual';
turn: '1' | '2';
state: string;
cityCode: string;
cityName: string;
zoneNumber: number;
candidateNumber: number;
candidateName: string;
coalition?: string | null;
party?: string | null;
voteType: 'nominal' | 'legenda';
votes: number;
updatedAt: string;
createdAt: string;
}
/**
* Registro de candidatura TSE (dado público).
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "electionCandidate".
*/
export interface ElectionCandidate {
id: number;
year: number;
office: 'presidente' | 'governador' | 'deputado_federal' | 'deputado_estadual';
turn: '1' | '2';
state: string;
candidateNumber: number;
urnaName: string;
completeName?: string | null;
party?: string | null;
coalition?: string | null;
candidateStatus?: string | null;
elected?: boolean | null;
electedBy?: ('QP' | 'média' | '2º turno') | null;
totalVotesState?: number | null;
/**
* sha256(nome urna + naturalidade + partido); só dados públicos.
*/
identityKey?: string | null;
runningAgain2026: 'sim' | 'nao' | 'desconhecido';
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
@ -735,6 +826,18 @@ export interface PayloadLockedDocument {
relationTo: 'nucleusUpdate';
value: number | NucleusUpdate;
} | null)
| ({
relationTo: 'electionTally';
value: number | ElectionTally;
} | null)
| ({
relationTo: 'electionCandidateVote';
value: number | ElectionCandidateVote;
} | null)
| ({
relationTo: 'electionCandidate';
value: number | ElectionCandidate;
} | null)
| ({
relationTo: 'media';
value: number | Media;
@ -988,6 +1091,79 @@ export interface NucleusUpdateSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "electionTally_select".
*/
export interface ElectionTallySelect<T extends boolean = true> {
year?: T;
office?: T;
turn?: T;
state?: T;
cityCode?: T;
cityName?: T;
zoneNumber?: T;
aptos?: T;
comparecimento?: T;
abstencoes?: T;
votosValidos?: T;
votosNominaisValidos?: T;
votosLegenda?: T;
votosBranco?: T;
votosNulo?: T;
votosAnulados?: T;
winnerCandidateNumber?: T;
winnerCandidateName?: T;
winnerVotes?: T;
winnerCoalition?: T;
winnerParty?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "electionCandidateVote_select".
*/
export interface ElectionCandidateVoteSelect<T extends boolean = true> {
year?: T;
office?: T;
turn?: T;
state?: T;
cityCode?: T;
cityName?: T;
zoneNumber?: T;
candidateNumber?: T;
candidateName?: T;
coalition?: T;
party?: T;
voteType?: T;
votes?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "electionCandidate_select".
*/
export interface ElectionCandidateSelect<T extends boolean = true> {
year?: T;
office?: T;
turn?: T;
state?: T;
candidateNumber?: T;
urnaName?: T;
completeName?: T;
party?: T;
coalition?: T;
candidateStatus?: T;
elected?: T;
electedBy?: T;
totalVotesState?: T;
identityKey?: T;
runningAgain2026?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".

View file

@ -11,6 +11,9 @@ import { CampaignInvite } from './collections/CampaignInvite'
import { CampaignUser } from './collections/CampaignUser'
import { Consent } from './collections/Consent'
import { Contact } from './collections/Contact'
import { ElectionCandidate } from './collections/ElectionCandidate'
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
import { ElectionTally } from './collections/ElectionTally'
import { ElectoralNucleus } from './collections/ElectoralNucleus'
import { Leadership } from './collections/Leadership'
import { Media } from './collections/Media'
@ -46,6 +49,9 @@ export default buildConfig({
ElectoralNucleus,
Leadership,
NucleusUpdate,
ElectionTally,
ElectionCandidateVote,
ElectionCandidate,
Media,
Petition,
Contact,

View file

@ -296,6 +296,13 @@ export const canManageNucleusLifecycle: FieldAccess = async ({ req }) => {
export const canSetDerivedNucleusField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
/** Public TSE election data: any authenticated campaign or admin user may read. */
export const canReadElectionData: Access = ({ req }) =>
isPayloadAdmin(req.user) || isCampaignUser(req.user)
/** Election reference data is mutated only by Payload admins (or CLI with overrideAccess). */
export const canMutateElectionData: Access = ({ req }) => isPayloadAdmin(req.user)
type DynamicFind = (args: {
collection: string
depth: number

View file

@ -0,0 +1,206 @@
import { and, eq } from '@payloadcms/db-postgres/drizzle'
import type { Payload } from 'payload'
import type {
CandidateVoteRow,
ElectionCandidateRow,
ElectionOffice,
ElectionTallyRow,
ElectionTurn,
TseDetalheApuracaoRow,
} from '@/lib/electionResults'
import { computeWinnersByScope, mergeTallyWithWinners } from '@/lib/electionResults'
import { groupByScope } from '@/lib/electionResultsParse'
const VOTE_TABLE = 'election_candidate_vote'
const TALLY_TABLE = 'election_tally'
const CANDIDATE_TABLE = 'election_candidate'
/** ~15 columns × 500 ≈ 7500 bind params — well under PG 65535. */
const INSERT_CHUNK_SIZE = 500
export type ElectionImportScope = {
year: number
office: ElectionOffice
turn: ElectionTurn
}
export type ElectionImportBundle = {
scope: ElectionImportScope
votes: CandidateVoteRow[]
tallies: TseDetalheApuracaoRow[]
candidates: ElectionCandidateRow[]
}
export type ElectionImportCounts = {
scope: ElectionImportScope
votesDeleted: number
votesInserted: number
talliesDeleted: number
talliesInserted: number
candidatesDeleted: number
candidatesInserted: number
}
type DrizzleDeleteResult = { rowCount?: number | null }
type DrizzleTx = {
delete: (table: unknown) => {
where: (condition: unknown) => Promise<DrizzleDeleteResult>
}
insert: (table: unknown) => {
values: (rows: Record<string, unknown>[]) => Promise<unknown>
}
}
type PayloadDb = {
drizzle: {
transaction: <T>(fn: (tx: DrizzleTx) => Promise<T>) => Promise<T>
}
tables: Record<string, Record<string, unknown>>
}
const chunk = <T>(rows: T[], size: number): T[][] => {
const out: T[][] = []
for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size))
return out
}
const getDb = (payload: Payload): PayloadDb => payload.db as unknown as PayloadDb
const requireTable = (db: PayloadDb, name: string) => {
const table = db.tables[name]
if (!table) throw new Error(`Tabela drizzle ausente: ${name}. Rode as migrations.`)
return table
}
const scopeWhere = (table: Record<string, unknown>, scope: ElectionImportScope) =>
and(
eq(table.year as never, scope.year),
eq(table.office as never, scope.office),
eq(table.turn as never, scope.turn),
)
const deleteScope = async (
tx: DrizzleTx,
table: Record<string, unknown>,
scope: ElectionImportScope,
): Promise<number> => {
// Avoid DELETE … RETURNING id (materializes every deleted row in memory on re-seed).
const result = await tx.delete(table).where(scopeWhere(table, scope))
return result.rowCount ?? 0
}
const nowIso = () => new Date().toISOString()
const withTimestamps = <T extends Record<string, unknown>>(row: T, now: string) => ({
...row,
createdAt: now,
updatedAt: now,
})
/**
* Replace all election data for a single (year, office, turn) scope in one transaction:
* delete existing rows, then bulk-insert the provided bundle.
*/
export const importElectionScope = async (
payload: Payload,
bundle: ElectionImportBundle,
): Promise<ElectionImportCounts> => {
const dbAdapter = getDb(payload)
const voteTable = requireTable(dbAdapter, VOTE_TABLE)
const tallyTable = requireTable(dbAdapter, TALLY_TABLE)
const candidateTable = requireTable(dbAdapter, CANDIDATE_TABLE)
const winners = computeWinnersByScope(bundle.votes)
const talliesWithWinners = mergeTallyWithWinners(bundle.tallies, winners)
const now = nowIso()
return dbAdapter.drizzle.transaction(async (tx) => {
const votesDeleted = await deleteScope(tx, voteTable, bundle.scope)
const talliesDeleted = await deleteScope(tx, tallyTable, bundle.scope)
const candidatesDeleted = await deleteScope(tx, candidateTable, bundle.scope)
for (const batch of chunk(bundle.votes, INSERT_CHUNK_SIZE)) {
if (batch.length > 0) {
await tx.insert(voteTable).values(batch.map((row) => withTimestamps(row, now)))
}
}
for (const batch of chunk(talliesWithWinners, INSERT_CHUNK_SIZE)) {
if (batch.length > 0) {
await tx.insert(tallyTable).values(
batch.map((row: ElectionTallyRow) => withTimestamps(row, now)),
)
}
}
for (const batch of chunk(bundle.candidates, INSERT_CHUNK_SIZE)) {
if (batch.length > 0) {
await tx.insert(candidateTable).values(batch.map((row) => withTimestamps(row, now)))
}
}
return {
scope: bundle.scope,
votesDeleted,
votesInserted: bundle.votes.length,
talliesDeleted,
talliesInserted: talliesWithWinners.length,
candidatesDeleted,
candidatesInserted: bundle.candidates.length,
}
})
}
/** Import many scopes sequentially (each scope is its own transaction). */
export const importElectionBundles = async (
payload: Payload,
bundles: readonly ElectionImportBundle[],
): Promise<ElectionImportCounts[]> => {
const results: ElectionImportCounts[] = []
for (const bundle of bundles) {
results.push(await importElectionScope(payload, bundle))
}
return results
}
/**
* Build import bundles from flat parsed arrays, grouping by (year, office, turn).
* Candidates without matching vote/tally scopes are still imported under their own scope.
*/
export const buildImportBundles = (args: {
votes: readonly CandidateVoteRow[]
tallies: readonly TseDetalheApuracaoRow[]
candidates: readonly ElectionCandidateRow[]
}): ElectionImportBundle[] => {
const votesByScope = groupByScope(args.votes)
const talliesByScope = groupByScope(args.tallies)
const candidatesByScope = groupByScope(args.candidates)
const keys = new Set([
...votesByScope.keys(),
...talliesByScope.keys(),
...candidatesByScope.keys(),
])
return [...keys].map((key) => {
const sample =
votesByScope.get(key)?.[0] ??
talliesByScope.get(key)?.[0] ??
candidatesByScope.get(key)?.[0]
if (!sample) {
throw new Error(`Empty election import scope key: ${key}`)
}
return {
scope: {
year: sample.year,
office: sample.office,
turn: sample.turn,
},
votes: votesByScope.get(key) ?? [],
tallies: talliesByScope.get(key) ?? [],
candidates: candidatesByScope.get(key) ?? [],
}
})
}

View file

@ -0,0 +1,8 @@
ANO_ELEICAO;NR_TURNO;SG_UF;CD_CARGO;NR_CANDIDATO;NM_URNA_CANDIDATO;NM_CANDIDATO;SG_PARTIDO;NM_COLIGACAO;DS_SITUACAO_CANDIDATURA;DS_SIT_TOT_TURNO;NM_MUNICIPIO_NASCIMENTO;SG_UF_NASCIMENTO
2022;1;BA;6;1313;JORGE SOLLA;JORGE JOSE SANTOS PEREIRA SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;DEFERIDO;ELEITO;SALVADOR;BA
2022;1;BA;6;2222;RIVAL FEDERAL;RIVAL DA SILVA;PL;PARTIDO LIBERAL;DEFERIDO;#NULO#;FEIRA DE SANTANA;BA
2022;1;BA;7;13131;ESTADUAL PT;CANDIDATO ESTADUAL;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;DEFERIDO;#NULO#;SALVADOR;BA
2022;1;BA;3;13;JERÔNIMO;JERONIMO RODRIGUES SOUZA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;DEFERIDO;2º TURNO;ACAJUTIBA;BA
2022;2;BA;3;13;JERÔNIMO;JERONIMO RODRIGUES SOUZA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;DEFERIDO;ELEITO;ACAJUTIBA;BA
2022;1;BA;3;45;RIVAL GOV;RIVAL GOVERNADOR;PSDB;COLIGAÇÃO OPOSIÇÃO;DEFERIDO;2º TURNO;SALVADOR;BA
2022;2;BA;3;45;RIVAL GOV;RIVAL GOVERNADOR;PSDB;COLIGAÇÃO OPOSIÇÃO;DEFERIDO;NÃO ELEITO;SALVADOR;BA
1 ANO_ELEICAO NR_TURNO SG_UF CD_CARGO NR_CANDIDATO NM_URNA_CANDIDATO NM_CANDIDATO SG_PARTIDO NM_COLIGACAO DS_SITUACAO_CANDIDATURA DS_SIT_TOT_TURNO NM_MUNICIPIO_NASCIMENTO SG_UF_NASCIMENTO
2 2022 1 BA 6 1313 JORGE SOLLA JORGE JOSE SANTOS PEREIRA SOLLA PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL DEFERIDO ELEITO SALVADOR BA
3 2022 1 BA 6 2222 RIVAL FEDERAL RIVAL DA SILVA PL PARTIDO LIBERAL DEFERIDO #NULO# FEIRA DE SANTANA BA
4 2022 1 BA 7 13131 ESTADUAL PT CANDIDATO ESTADUAL PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL DEFERIDO #NULO# SALVADOR BA
5 2022 1 BA 3 13 JERÔNIMO JERONIMO RODRIGUES SOUZA PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL DEFERIDO 2º TURNO ACAJUTIBA BA
6 2022 2 BA 3 13 JERÔNIMO JERONIMO RODRIGUES SOUZA PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL DEFERIDO ELEITO ACAJUTIBA BA
7 2022 1 BA 3 45 RIVAL GOV RIVAL GOVERNADOR PSDB COLIGAÇÃO OPOSIÇÃO DEFERIDO 2º TURNO SALVADOR BA
8 2022 2 BA 3 45 RIVAL GOV RIVAL GOVERNADOR PSDB COLIGAÇÃO OPOSIÇÃO DEFERIDO NÃO ELEITO SALVADOR BA

View file

@ -0,0 +1,5 @@
ANO_ELEICAO;NR_TURNO;SG_UF;CD_CARGO;NR_CANDIDATO;NM_URNA_CANDIDATO;NM_CANDIDATO;SG_PARTIDO;NM_COLIGACAO;DS_SITUACAO_CANDIDATURA;DS_SIT_TOT_TURNO;NM_MUNICIPIO_NASCIMENTO;SG_UF_NASCIMENTO
2022;1;BR;1;13;LULA;LUIZ INACIO LULA DA SILVA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;DEFERIDO;2º TURNO;GARANHUNS;PE
2022;2;BR;1;13;LULA;LUIZ INACIO LULA DA SILVA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;DEFERIDO;ELEITO;GARANHUNS;PE
2022;1;BR;1;22;BOLSONARO;JAIR MESSIAS BOLSONARO;PL;PARTIDO LIBERAL;DEFERIDO;2º TURNO;CAMPINAS;SP
2022;2;BR;1;22;BOLSONARO;JAIR MESSIAS BOLSONARO;PL;PARTIDO LIBERAL;DEFERIDO;NÃO ELEITO;CAMPINAS;SP
1 ANO_ELEICAO NR_TURNO SG_UF CD_CARGO NR_CANDIDATO NM_URNA_CANDIDATO NM_CANDIDATO SG_PARTIDO NM_COLIGACAO DS_SITUACAO_CANDIDATURA DS_SIT_TOT_TURNO NM_MUNICIPIO_NASCIMENTO SG_UF_NASCIMENTO
2 2022 1 BR 1 13 LULA LUIZ INACIO LULA DA SILVA PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL DEFERIDO 2º TURNO GARANHUNS PE
3 2022 2 BR 1 13 LULA LUIZ INACIO LULA DA SILVA PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL DEFERIDO ELEITO GARANHUNS PE
4 2022 1 BR 1 22 BOLSONARO JAIR MESSIAS BOLSONARO PL PARTIDO LIBERAL DEFERIDO 2º TURNO CAMPINAS SP
5 2022 2 BR 1 22 BOLSONARO JAIR MESSIAS BOLSONARO PL PARTIDO LIBERAL DEFERIDO NÃO ELEITO CAMPINAS SP

View file

@ -0,0 +1,9 @@
ANO_ELEICAO;NR_TURNO;SG_UF;CD_MUNICIPIO;NM_MUNICIPIO;NR_ZONA;CD_CARGO;QT_APTOS;QT_COMPARECIMENTO;QT_ABSTENCOES;QT_VOTOS_VALIDOS;QT_VOTOS_NOMINAIS_VALIDOS;QT_VOTOS_LEGENDA;QT_VOTOS_BRANCOS;QT_VOTOS_NULOS;QT_VOTOS_ANULADOS
2022;1;BA;38490;SALVADOR;1;6;10000;8000;2000;7500;7300;200;300;200;0
2022;1;BA;38490;SALVADOR;2;6;9000;7200;1800;6800;6600;200;250;150;0
2022;1;BA;35150;FEIRA DE SANTANA;10;6;5000;4000;1000;3800;3700;100;120;80;0
2022;1;BA;38490;SALVADOR;1;7;10000;8000;2000;7400;7200;200;300;300;0
2022;1;BA;38490;SALVADOR;1;3;10000;8000;2000;7600;7400;200;200;200;0
2022;2;BA;38490;SALVADOR;1;3;10000;8200;1800;7800;7600;200;200;200;0
2022;1;BA;38490;SALVADOR;1;1;10000;8000;2000;7700;7500;200;150;150;0
2022;2;BA;38490;SALVADOR;1;1;10000;8300;1700;7900;7700;200;200;200;0
1 ANO_ELEICAO NR_TURNO SG_UF CD_MUNICIPIO NM_MUNICIPIO NR_ZONA CD_CARGO QT_APTOS QT_COMPARECIMENTO QT_ABSTENCOES QT_VOTOS_VALIDOS QT_VOTOS_NOMINAIS_VALIDOS QT_VOTOS_LEGENDA QT_VOTOS_BRANCOS QT_VOTOS_NULOS QT_VOTOS_ANULADOS
2 2022 1 BA 38490 SALVADOR 1 6 10000 8000 2000 7500 7300 200 300 200 0
3 2022 1 BA 38490 SALVADOR 2 6 9000 7200 1800 6800 6600 200 250 150 0
4 2022 1 BA 35150 FEIRA DE SANTANA 10 6 5000 4000 1000 3800 3700 100 120 80 0
5 2022 1 BA 38490 SALVADOR 1 7 10000 8000 2000 7400 7200 200 300 300 0
6 2022 1 BA 38490 SALVADOR 1 3 10000 8000 2000 7600 7400 200 200 200 0
7 2022 2 BA 38490 SALVADOR 1 3 10000 8200 1800 7800 7600 200 200 200 0
8 2022 1 BA 38490 SALVADOR 1 1 10000 8000 2000 7700 7500 200 150 150 0
9 2022 2 BA 38490 SALVADOR 1 1 10000 8300 1700 7900 7700 200 200 200 0

View file

@ -0,0 +1,17 @@
ANO_ELEICAO;NR_TURNO;SG_UF;CD_MUNICIPIO;NM_MUNICIPIO;NR_ZONA;CD_CARGO;NR_CANDIDATO;NM_URNA_CANDIDATO;SG_PARTIDO;NM_COLIGACAO;QT_VOTOS_NOMINAIS;DS_SIT_TOT_TURNO
2022;1;BA;38490;SALVADOR;1;6;1313;JORGE SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;1200;ELEITO
2022;1;BA;38490;SALVADOR;1;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;800;#NULO#
2022;1;BA;38490;SALVADOR;2;6;1313;JORGE SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;900;ELEITO
2022;1;BA;38490;SALVADOR;2;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;1100;#NULO#
2022;1;BA;35150;FEIRA DE SANTANA;10;6;1313;JORGE SOLLA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;500;ELEITO
2022;1;BA;35150;FEIRA DE SANTANA;10;6;2222;RIVAL FEDERAL;PL;PARTIDO LIBERAL;300;#NULO#
2022;1;BA;38490;SALVADOR;1;7;13131;ESTADUAL PT;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;400;#NULO#
2022;1;BA;38490;SALVADOR;1;3;13;JERÔNIMO;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;5000;2º TURNO
2022;1;BA;38490;SALVADOR;1;3;45;RIVAL GOV;PSDB;COLIGAÇÃO OPOSIÇÃO;4200;2º TURNO
2022;2;BA;38490;SALVADOR;1;3;13;JERÔNIMO;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;6000;ELEITO
2022;2;BA;38490;SALVADOR;1;3;45;RIVAL GOV;PSDB;COLIGAÇÃO OPOSIÇÃO;4500;NÃO ELEITO
2022;1;BA;38490;SALVADOR;1;1;13;LULA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;7000;2º TURNO
2022;1;BA;38490;SALVADOR;1;1;22;BOLSONARO;PL;PARTIDO LIBERAL;3000;2º TURNO
2022;2;BA;38490;SALVADOR;1;1;13;LULA;PT;FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL;8000;ELEITO
2022;2;BA;38490;SALVADOR;1;1;22;BOLSONARO;PL;PARTIDO LIBERAL;3500;NÃO ELEITO
2022;1;BA;38490;SALVADOR;1;6;9999;ZERO VOTOS;PSD;#NULO#;0;#NULO#
1 ANO_ELEICAO NR_TURNO SG_UF CD_MUNICIPIO NM_MUNICIPIO NR_ZONA CD_CARGO NR_CANDIDATO NM_URNA_CANDIDATO SG_PARTIDO NM_COLIGACAO QT_VOTOS_NOMINAIS DS_SIT_TOT_TURNO
2 2022 1 BA 38490 SALVADOR 1 6 1313 JORGE SOLLA PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL 1200 ELEITO
3 2022 1 BA 38490 SALVADOR 1 6 2222 RIVAL FEDERAL PL PARTIDO LIBERAL 800 #NULO#
4 2022 1 BA 38490 SALVADOR 2 6 1313 JORGE SOLLA PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL 900 ELEITO
5 2022 1 BA 38490 SALVADOR 2 6 2222 RIVAL FEDERAL PL PARTIDO LIBERAL 1100 #NULO#
6 2022 1 BA 35150 FEIRA DE SANTANA 10 6 1313 JORGE SOLLA PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL 500 ELEITO
7 2022 1 BA 35150 FEIRA DE SANTANA 10 6 2222 RIVAL FEDERAL PL PARTIDO LIBERAL 300 #NULO#
8 2022 1 BA 38490 SALVADOR 1 7 13131 ESTADUAL PT PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL 400 #NULO#
9 2022 1 BA 38490 SALVADOR 1 3 13 JERÔNIMO PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL 5000 2º TURNO
10 2022 1 BA 38490 SALVADOR 1 3 45 RIVAL GOV PSDB COLIGAÇÃO OPOSIÇÃO 4200 2º TURNO
11 2022 2 BA 38490 SALVADOR 1 3 13 JERÔNIMO PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL 6000 ELEITO
12 2022 2 BA 38490 SALVADOR 1 3 45 RIVAL GOV PSDB COLIGAÇÃO OPOSIÇÃO 4500 NÃO ELEITO
13 2022 1 BA 38490 SALVADOR 1 1 13 LULA PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL 7000 2º TURNO
14 2022 1 BA 38490 SALVADOR 1 1 22 BOLSONARO PL PARTIDO LIBERAL 3000 2º TURNO
15 2022 2 BA 38490 SALVADOR 1 1 13 LULA PT FEDERAÇÃO BRASIL DA ESPERANÇA - FE BRASIL 8000 ELEITO
16 2022 2 BA 38490 SALVADOR 1 1 22 BOLSONARO PL PARTIDO LIBERAL 3500 NÃO ELEITO
17 2022 1 BA 38490 SALVADOR 1 6 9999 ZERO VOTOS PSD #NULO# 0 #NULO#

View file

@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { parseTseCsvString } from '@/lib/electionResultsCsv'
import { buildElectionResultsFromCsvRows } from '@/lib/electionResultsBuild'
import type { BuiltElectionResults } from '@/lib/electionResultsBuild'
const fixtureDir = join(process.cwd(), 'tests/fixtures/tse')
const readFixture = (name: string): string => readFileSync(join(fixtureDir, name), 'utf8')
export const loadTseFixtureResults = (): BuiltElectionResults => {
const voteRows = parseTseCsvString(readFixture('votacao_candidato_munzona_fixture.csv'))
const detalheRows = parseTseCsvString(readFixture('detalhe_votacao_munzona_fixture.csv'))
const candBaRows = parseTseCsvString(readFixture('consulta_cand_ba_fixture.csv'))
const candBrRows = parseTseCsvString(readFixture('consulta_cand_br_fixture.csv'))
return buildElectionResultsFromCsvRows({
voteRows,
detalheRows,
candBaRows,
candBrRows,
})
}
/** Known totals from the fixture (after skipping the zero-vote row). */
export const TSE_FIXTURE_EXPECTED = {
sollaVotesTotal: 1200 + 900 + 500,
lulaTurn2SalvadorZ1: 8000,
jeronimoTurn2SalvadorZ1: 6000,
federalWinnerSalvadorZ2: { candidateNumber: 2222, votes: 1100 },
federalWinnerSalvadorZ1: { candidateNumber: 1313, votes: 1200 },
voteRowCount: 15, // 16 CSV rows minus the zero-vote row
tallyRowCount: 8,
} as const

View file

@ -0,0 +1,186 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { getPayload, type Payload } from 'payload'
import config from '@/payload.config'
import { SOLLA_CANDIDATE_NUMBER } from '@/lib/electionResults'
import {
buildImportBundles,
importElectionBundles,
} from '@/utilities/electionResultsImport'
import { loadTseFixtureResults, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures'
let payload: Payload
const deleteAllElectionData = async () => {
for (const collection of [
'electionCandidateVote',
'electionTally',
'electionCandidate',
] as const) {
const existing = await payload.find({
collection,
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
for (const doc of existing.docs) {
await payload.delete({
collection,
id: doc.id,
overrideAccess: true,
})
}
}
}
describe('election results import', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
await deleteAllElectionData()
})
afterAll(async () => {
await deleteAllElectionData()
})
it('imports fixture totals, winners, elected flags, and is idempotent', async () => {
const built = loadTseFixtureResults()
const bundles = buildImportBundles(built)
const first = await importElectionBundles(payload, bundles)
const second = await importElectionBundles(payload, bundles)
const firstVotes = first.reduce((sum, c) => sum + c.votesInserted, 0)
const secondVotes = second.reduce((sum, c) => sum + c.votesInserted, 0)
expect(firstVotes).toBe(TSE_FIXTURE_EXPECTED.voteRowCount)
expect(secondVotes).toBe(firstVotes)
expect(second.reduce((sum, c) => sum + c.votesDeleted, 0)).toBe(firstVotes)
const votes = await payload.find({
collection: 'electionCandidateVote',
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
expect(votes.totalDocs).toBe(TSE_FIXTURE_EXPECTED.voteRowCount)
const sollaVotes = votes.docs
.filter(
(doc) =>
doc.office === 'deputado_federal' &&
Number(doc.candidateNumber) === SOLLA_CANDIDATE_NUMBER,
)
.reduce((sum, doc) => sum + Number(doc.votes), 0)
expect(sollaVotes).toBe(TSE_FIXTURE_EXPECTED.sollaVotesTotal)
const tallies = await payload.find({
collection: 'electionTally',
where: {
and: [
{ office: { equals: 'deputado_federal' } },
{ cityCode: { equals: '38490' } },
{ zoneNumber: { equals: 1 } },
{ turn: { equals: '1' } },
],
},
depth: 0,
limit: 1,
overrideAccess: true,
})
expect(tallies.docs[0]?.winnerCandidateNumber).toBe(
TSE_FIXTURE_EXPECTED.federalWinnerSalvadorZ1.candidateNumber,
)
expect(Number(tallies.docs[0]?.winnerVotes)).toBe(
TSE_FIXTURE_EXPECTED.federalWinnerSalvadorZ1.votes,
)
const candidates = await payload.find({
collection: 'electionCandidate',
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
const solla = candidates.docs.find(
(doc) => doc.office === 'deputado_federal' && Number(doc.candidateNumber) === 1313,
)
const lula = candidates.docs.find(
(doc) =>
doc.office === 'presidente' &&
Number(doc.candidateNumber) === 13 &&
doc.turn === '2',
)
const jeronimo = candidates.docs.find(
(doc) =>
doc.office === 'governador' &&
Number(doc.candidateNumber) === 13 &&
doc.turn === '2',
)
expect(solla?.elected).toBe(true)
expect(lula?.elected).toBe(true)
expect(jeronimo?.elected).toBe(true)
expect(lula?.identityKey).toBeTruthy()
})
it('allows campaignUser read and denies campaignUser mutation', async () => {
// Avoid campaignFixtures here: teqo_test may already include territory migrations
// from other worktrees that diverge from this branch's electoralNucleus schema.
const campaignUser = await payload.create({
collection: 'campaignUser',
data: {
name: 'Election Access Tester',
role: 'geral',
email: `election-access-${Date.now()}@example.com`,
password: 'Password123!',
},
overrideAccess: true,
})
try {
const readable = await payload.find({
collection: 'electionTally',
depth: 0,
limit: 1,
user: campaignUser,
overrideAccess: false,
})
expect(readable.totalDocs).toBeGreaterThan(0)
await expect(
payload.create({
collection: 'electionTally',
data: {
year: 2022,
office: 'deputado_federal',
turn: '1',
state: 'BA',
cityCode: '99999',
cityName: 'Salvador',
zoneNumber: 99,
aptos: 1,
comparecimento: 1,
abstencoes: 0,
votosValidos: 1,
votosNominaisValidos: 1,
votosLegenda: 0,
votosBranco: 0,
votosNulo: 0,
votosAnulados: 0,
},
user: campaignUser,
overrideAccess: false,
}),
).rejects.toThrow()
} finally {
await payload.delete({
collection: 'campaignUser',
id: campaignUser.id,
overrideAccess: true,
})
}
})
})

View file

@ -0,0 +1,161 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { bahiaMunicipalities } from '@/lib/bahiaTerritories'
import { computeIdentityKey, normalizeIdentityPart } from '@/lib/electionCandidateIdentity'
import {
assertAllCanonicalMunicipalitiesResolvable,
canonicalizeMunicipalityName,
computeWinnersByScope,
JERONIMO_CANDIDATE_NUMBER,
LULA_CANDIDATE_NUMBER,
mergeTallyWithWinners,
normalizeMunicipalityKey,
parseElectedStatus,
SOLLA_CANDIDATE_NUMBER,
UnknownMunicipalityError,
winnerKey,
} from '@/lib/electionResults'
import { loadTseFixtureResults, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures'
describe('electionResults municipality mapping', () => {
it('resolves every canonical Bahia municipality through itself', () => {
expect(bahiaMunicipalities).toHaveLength(417)
expect(() => assertAllCanonicalMunicipalitiesResolvable()).not.toThrow()
})
it('maps TSE ALL-CAPS names to canonical names', () => {
expect(canonicalizeMunicipalityName('SALVADOR')).toBe('Salvador')
expect(canonicalizeMunicipalityName('FEIRA DE SANTANA')).toBe('Feira de Santana')
expect(canonicalizeMunicipalityName("DIAS D'AVILA")).toBe("Dias d'Ávila")
expect(canonicalizeMunicipalityName('XIQUE-XIQUE')).toBe('Xique-Xique')
})
it('maps known TSE spelling aliases to canonical names', () => {
expect(canonicalizeMunicipalityName('CAMACÃ')).toBe('Camacan')
expect(canonicalizeMunicipalityName('DIAS D ÁVILA')).toBe("Dias d'Ávila")
expect(canonicalizeMunicipalityName('MUQUÉM DO SÃO FRANCISCO')).toBe(
'Muquém de São Francisco',
)
expect(canonicalizeMunicipalityName('SANTA TEREZINHA')).toBe('Santa Teresinha')
})
it('fails closed on unknown municipality names', () => {
expect(() => canonicalizeMunicipalityName('CIDADE INEXISTENTE')).toThrow(UnknownMunicipalityError)
})
it('normalizes accent and apostrophe variants to the same key', () => {
expect(normalizeMunicipalityKey("Dias d'Ávila")).toBe(normalizeMunicipalityKey("DIAS D'AVILA"))
})
})
describe('electionResults elected status', () => {
it('maps DS_SIT_TOT_TURNO variants', () => {
expect(parseElectedStatus('ELEITO')).toEqual({ elected: true, electedBy: 'QP' })
expect(parseElectedStatus('ELEITO POR MÉDIA')).toEqual({ elected: true, electedBy: 'média' })
expect(parseElectedStatus('ELEITO EM 2º TURNO')).toEqual({
elected: true,
electedBy: '2º turno',
})
expect(parseElectedStatus('2º TURNO')).toEqual({ elected: false, electedBy: null })
expect(parseElectedStatus('NÃO ELEITO')).toEqual({ elected: false, electedBy: null })
expect(parseElectedStatus('#NULO#')).toEqual({ elected: false, electedBy: null })
})
})
describe('electionCandidateIdentity', () => {
it('is stable for equivalent normalized inputs', () => {
const a = computeIdentityKey({
urnaName: 'Jorge Solla',
birthCity: 'Salvador',
birthState: 'BA',
party: 'PT',
})
const b = computeIdentityKey({
urnaName: 'JORGE SOLLA',
birthCity: 'SALVADOR',
birthState: 'ba',
party: 'pt',
})
expect(a).toBe(b)
expect(a).toHaveLength(64)
})
it('changes when party changes', () => {
const base = {
urnaName: 'LULA',
birthCity: 'GARANHUNS',
birthState: 'PE',
party: 'PT',
}
expect(computeIdentityKey(base)).not.toBe(computeIdentityKey({ ...base, party: 'PL' }))
})
it('strips accents in identity parts', () => {
expect(normalizeIdentityPart('Jerônimo')).toBe('JERONIMO')
})
})
describe('electionResults fixture parse + winners', () => {
const built = loadTseFixtureResults()
it('builds expected vote totals and skips zero-vote rows', () => {
expect(built.unknownMunicipalities).toEqual([])
expect(built.votes).toHaveLength(TSE_FIXTURE_EXPECTED.voteRowCount)
expect(built.tallies).toHaveLength(TSE_FIXTURE_EXPECTED.tallyRowCount)
const sollaVotes = built.votes
.filter(
(row) =>
row.office === 'deputado_federal' && row.candidateNumber === SOLLA_CANDIDATE_NUMBER,
)
.reduce((sum, row) => sum + row.votes, 0)
expect(sollaVotes).toBe(TSE_FIXTURE_EXPECTED.sollaVotesTotal)
})
it('marks Solla, Lula and Jerônimo as elected', () => {
const solla = built.candidates.find(
(c) => c.office === 'deputado_federal' && c.candidateNumber === SOLLA_CANDIDATE_NUMBER,
)
const lula = built.candidates.find(
(c) =>
c.office === 'presidente' &&
c.candidateNumber === LULA_CANDIDATE_NUMBER &&
c.turn === '2',
)
const jeronimo = built.candidates.find(
(c) =>
c.office === 'governador' &&
c.candidateNumber === JERONIMO_CANDIDATE_NUMBER &&
c.turn === '2',
)
expect(solla?.elected).toBe(true)
expect(lula?.elected).toBe(true)
expect(lula?.electedBy).toBe('2º turno')
expect(jeronimo?.elected).toBe(true)
expect(jeronimo?.electedBy).toBe('2º turno')
expect(lula?.state).toBe('BA')
})
it('computes local winners per city+zone', () => {
const winners = computeWinnersByScope(built.votes)
const z1 = winners.get(winnerKey('38490', 1, 'deputado_federal', '1'))
const z2 = winners.get(winnerKey('38490', 2, 'deputado_federal', '1'))
expect(z1?.winnerCandidateNumber).toBe(
TSE_FIXTURE_EXPECTED.federalWinnerSalvadorZ1.candidateNumber,
)
expect(z1?.winnerVotes).toBe(TSE_FIXTURE_EXPECTED.federalWinnerSalvadorZ1.votes)
expect(z2?.winnerCandidateNumber).toBe(
TSE_FIXTURE_EXPECTED.federalWinnerSalvadorZ2.candidateNumber,
)
expect(z2?.winnerVotes).toBe(TSE_FIXTURE_EXPECTED.federalWinnerSalvadorZ2.votes)
const merged = mergeTallyWithWinners(built.tallies, winners)
const federalZ1 = merged.find(
(t) => t.office === 'deputado_federal' && t.zoneNumber === 1 && t.cityCode === '38490',
)
expect(federalZ1?.winnerCandidateName).toBe('JORGE SOLLA')
})
})