Import projection-sheet strategy columns into municipalities (E4R fase 2)

- pnpm db:seed:projecao now also imports SITUAÇÃO -> politicalTrend,
  DOBRADINHAS -> stateDeputy + stateDeputies + dobradinhaNotes,
  LIDERANÇAS -> name-only contact + leadership (statewide slug dedupe),
  ASSESSOR RESPONSÁVEL -> campaignUser + advisors (Edizio=coordinator,
  Solla=candidate, placeholder @planilha.invalid emails),
  ENCAMINHAMENTOS -> nextSteps, OBSERVAÇÃO -> strengths/risks (curated).
  PRIORITÁRIAS overrides MAPA GERAL when non-empty; Salvador stays skipped;
  relationships are unions; re-run is idempotent.
- parseSituationCell + splitNameCell (noise/collective/action filters,
  honorific protection, parenthetical extraction) with unit tests.
- Contact.phone optional (migration 20260724_175500_contact_phone_optional);
  phone invariant hooks skip phone-less contacts; invite flows guard.
- candidate role now eligible for municipality/action-plan advisors
  (eligibleCampaignStaffWhere) per product decision.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-24 15:58:06 -03:00
parent c2a1570b0c
commit 0407f02ce2
23 changed files with 1101 additions and 94 deletions

View file

@ -9,7 +9,7 @@ alwaysApply: false
- **B15 registrado (2026-07-24):** ordenar `/campanha/municipios` pelo header da coluna — contrato URL `?sort=`/`?dir=`, clique no header (desktop) + select mobile; A11 consome a key `votos`. Plano: [`ordenacao-colunas-lista-municipios.md`](../../../docs/plans/ordenacao-colunas-lista-municipios.md).
- **Fill-in registrado (2026-07-24):** seletor **Cenário** de `/campanha/municipios` → fileira de `MunicipalityFilters` (provider envolve filtros+resultados; overview sem seletor). Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md).
- **E4R entregue em código (2026-07-24):** `pnpm db:seed:projecao` (`scripts/import-projecao.mjs` + `src/lib/projectionSheetParse.ts`) importa MAPA GERAL canônico → `expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` com **overwrite-always** (re-seed quando chegar planilha nova); Salvador pulado; zero PII; dry-run + runbook. No mesmo dia o grupo duplicado `voteGoals` foi removido do app (ver Decisões). Seed local: arquivo `docs/sheets/Mapa projeção de votos Solla 2026.xlsx`, **189** municípios com estimativas, **50** `priority=alta`, re-run delta=0. Produção: após smoke, `ALLOW_REMOTE_DB=true … pnpm db:seed:projecao -- --dry-run` → apply. Plano: [`import-planilha-projecao.md`](../../../docs/plans/import-planilha-projecao.md).
- **E4R entregue em código + seed em produção (2026-07-24):** `pnpm db:seed:projecao` (`scripts/import-projecao.mjs` + `src/lib/projectionSheetParse.ts`) importa MAPA GERAL canônico → `expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` com **overwrite-always**; Salvador pulado; zero PII. No mesmo dia o grupo duplicado `voteGoals` foi removido do app (ver Decisões). Seed local e **produção Neon** (2026-07-24): arquivo canônico `docs/sheets/Mapa projeção de votos Solla 2026.xlsx` (+ referência `Mapa_projecao_votos_Solla_2026.xlsx` só A×B), **189** municípios atualizados, **50** `priority=alta`, Salvador pulado, re-dry-run pós-apply **delta=0**. Plano: [`import-planilha-projecao.md`](../../../docs/plans/import-planilha-projecao.md).
- **Sync roadmap × sessão de campo (2026-07-24):** os achados da sessão real do CG (2026-07-23, `docs/CUSTOMER.md`) foram incorporados ao roadmap e aos planos de inteligência — âncora da mesa = **% da própria votação** (A11 entrega a lente; E9/E10/B13 expõem ao lado de LQ/captura); piso 150 mil resolve a meta inicial de E8; O6 vira **E16 dossiê do município** + gap **G11 emendas por município**; O7 confirma C12 não cortável; E13 rebaixado na fila de corte ("perna" é a restrição dominante); gatilho de D3 reformulado (deltas do ZAP não absorvidos pelo fluxo sede-digita/C12); FD2 absorvido por R6; O8 "ilhas isoladas" em A validar. **Decisão de produto: E4 reaberto como E4R** — seed único via script das planilhas de `docs/sheets/` (arquivo com espaços = canônico de estratégia; underscore = referência Salvador; v1 só `expectedVotes`/`priority`, sem PII; Salvador pulado com relatório). Novos itens: **E4R** (janela 1, não cortável), **A11**, **E17 tabela TI no Início** (pedido do candidato; primeira fatia de E12), **B14 município mais próximo** (geolocalização → atalho no Início staff; prompt 1×/sessão; client-only), **B15 ordenar lista por coluna**. Planos novos: `import-planilha-projecao.md`, `ranking-votos-municipio.md`, `tabela-ti-inicio.md`, `dossie-municipio.md`, `municipio-mais-proximo.md`, `ordenacao-colunas-lista-municipios.md`, `cenario-junto-filtros-municipios.md`.
- **DEPLOY EM PRODUÇÃO REALIZADO (2026-07-23).** A cadeia destrutiva da remodelagem (`20260721_020109_remodel_municipalities` → `20260723_200000_remodel_municipalities` + `20260723_202000_reconcile_municipality_remodel`) foi aplicada em produção pelos builds Vercel de 2026-07-23 (deploys production READY dos commits `a7b8f1b`/`c7a1b74`/`59868a9`). **Este item supersede toda menção "deploy pendente" nas entradas abaixo.** Pendências operacionais: smoke pós-deploy + onboarding do time (Onda 0 §2/§4 em [`docs/roadmap.md`](../../../docs/roadmap.md)); hold de PII real segue no lote jurídico.
- **Hardening de engenharia entregue e em produção (2026-07-23, Fases 0–6):** (0) access lockdown — `users`/`signature`/`subscription`/`consent` admin-only, escrita de CMS (`post`/`tag`/`media`/`petition`) admin-only, gates de rota do leader em `demandas*`/`planos*`, `createLeaderSupporter` com escopo correto, `candidate` tratado como staff irrestrito em todas as superfícies; (1) tooling gate — knip no CI (`.github/workflows/ci.yml`, mirror GitHub), `pnpm lint --max-warnings=0`, ban `as never` (helpers `hookFilledCreateData`/`stub<T>`), tsconfig consolidado + `noUnusedLocals`, ~12 módulos mortos deletados; (2) caching — artefato TSE commitado (`pnpm build:election-aggregates` → `src/lib/electionAggregates/`, loader `bahiaElectionAggregates.ts`), `unstable_cache` com tag `election-tse` nos loaders de eleições, `loadMunicipalityScope` (React `cache()`) dedupe dashboard+mapa+lista; (3) pending honesto — bug de revalidação do toggle de tarefa, `CampaignListPendingBoundary` esmaecendo resultados, transição no compare do mapa, mapa do dashboard + abas do detalhe em Suspense; (4) state scoping — hover do mapa isolado em `MunicipalityMapSelection`, provider de cenário estreitado, `LeaderContactForm` extraído como ilha cliente; (5) estrutura — `campaignAccess` dividido em `src/utilities/access/*` (re-export preservado), `supporterImport.ts` separado, `runStaffEntityMutation` + `runCampaignRedirectFormAction` dedupando twins e 4 ladders de formActions, rename `PLAZA_*`→`MUNICIPALITY_*` concluído; (6) docs + regra `.cursor/rules/engineering-standards.mdc`. Tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](../../../docs/IMPROVE-CODE-QUALITY-PLAN.md) · ledger: [`docs/TECH-DEBT.md`](../../../docs/TECH-DEBT.md) · mapa de testes: [`docs/TESTING.md`](../../../docs/TESTING.md).
@ -144,6 +144,7 @@ alwaysApply: false
- Em 2026-07-19, decisões de produto da Trilha E (mapa de projeção): **sem entidade nova por município** — `electoralNucleus` absorve a camada estratégica (metas, prioridade, dobradinhas) e a visão município-a-município é derivada de núcleos + baseline TSE agregados por `cities[]`; **série histórica 2014/2018 vem do TSE oficial** (seed A3 parametrizado por ano, só dep. federal turno 1 BA), não dos números da planilha; **E4 import da planilha cortado** — dados estratégicos entram manualmente via UI E1+E3 (metas, prioridade, dobradinhas, encaminhamentos); o CRM real (`Contact`/`leadership`) continua pelo fluxo com Consent; tendência (Queda/Mantém/Aumento) é **derivada em leitura** (`computeVoteTrend`, limiares versionados), não persistida.
- Em 2026-07-19, insight “oportunidade de virada” absorvido como **Fase 2** de [insight-alavancagem-chapa.md](../../../docs/plans/insight-alavancagem-chapa.md) (A5): majoritários locais de esquerda + (vencedor federal de direita **ou** share da direita nos nominais ≥ limiar); mapa estático de espectro partidário; sem item paralelo.
- Em 2026-07-19, **A8 Perfis do eleitorado IBGE registrado:** perfil médio/comum derivado de indicadores municipais do Censo (artefato estático + ponderação por `cities[]`), exibido na aba Eleitorado junto com `voterProfiles` manuais; calculado não sobrescreve o array; sem migration/Consent. Plano: [`docs/plans/perfil-eleitorado-ibge.md`](../../../docs/plans/perfil-eleitorado-ibge.md).
- Em 2026-07-24, **E4R fase 2 entregue (decisão do usuário — supera o corte "só números" de E4R):** `pnpm db:seed:projecao` importa também SITUAÇÃO→`politicalTrend` (com nota de proveniência), DOBRADINHAS→`stateDeputy`+`stateDeputies`+`dobradinhaNotes`, LIDERANÇAS→`contact` name-only sem telefone+`leadership` (dedup por slug), ASSESSOR→`campaignUser`+`advisors` (Edizio=coordinator, Solla=candidate; email placeholder `@planilha.invalid`), ENCAMINHAMENTOS→`nextSteps`, OBSERVAÇÃO→forças/riscos curadas; PRIORITÁRIAS sobrepõe MAPA GERAL; Salvador segue pulado (decisão do usuário); relações por união; re-run idempotente. Mudanças de modelo: `Contact.phone` opcional (migration `20260724_175500_contact_phone_optional`) e `candidate` elegível como assessor de município/plano (`eligibleCampaignStaffWhere`). Nomes políticos entraram sem trilha de consentimento — regularizar com a assessoria. Plano: [`docs/plans/import-planilha-projecao.md`](../../../docs/plans/import-planilha-projecao.md).
- Fonte: `/Users/francisco.solla/.cursor/plans/núcleos_eleitorais_mvp_7f788d29.plan.md`.
## Bloqueadores e próximas frentes

View file

@ -61,7 +61,7 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-23):** **Engineering-standards hardening** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md); ledger: [`docs/TECH-DEBT.md`](docs/TECH-DEBT.md); safety net: [`docs/TESTING.md`](docs/TESTING.md); standards rule: `.cursor/rules/engineering-standards.mdc` — READ IT before coding). Phases: (0) explicit `access` on every collection — `users`/`signature`/`subscription`/`consent` admin-only, CMS writes admin-only, leader route gates on `demandas*`/`planos*`, `createLeaderSupporter` scope fixed, `candidate` treated as unrestricted staff consistently; (1) tooling gate — knip in CI (`.github/workflows/ci.yml` on the GitHub mirror), `pnpm lint --max-warnings=0`, `as never` banned (typed helpers `hookFilledCreateData` / tests' `stub<T>`), tsconfig include consolidated + `noUnusedLocals`, ~12 dead modules deleted; (2) caching — committed TSE aggregate artifact (`pnpm build:election-aggregates` → `src/lib/electionAggregates/`, loader `bahiaElectionAggregates.ts`), `loadMunicipalityScope` React `cache()` dedup, `unstable_cache` + `election-tse` tag on elections-tab loaders; (3) "Feel the action" gaps closed — task-toggle revalidation bug, shared `CampaignListPendingBoundary` dimming results, map-compare transition, dashboard map + detail tabs stream behind Suspense; (4) state scoping — map hover isolated in `MunicipalityMapSelection`, scenario provider narrowed, `LeaderContactsPanel` server-rendered with a client form island; (5) structure — `campaignAccess` split into `src/utilities/access/*` (re-exported), supporter actions split (`supporterImport.ts`), `runStaffEntityMutation` + `runCampaignRedirectFormAction` dedupe the org/dobradinha twins and 4 formActions ladders, Plaza→Municipality identifier rename finished.
**Recently resolved (2026-07-24):** **E4R** Import único da planilha de projeção — `pnpm db:seed:projecao` (`scripts/import-projecao.mjs`) lê o xlsx canônico em `docs/sheets/`, faz match via `canonicalizeMunicipalityName` + `municipalityCatalog`, e **sempre sobrescreve** `municipality.expectedVotes` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; zero PII; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). No mesmo dia, o grupo duplicado `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido** — collection, zod, view models, form/card e utilitário `voteGoals.ts` — via migration `20260724_133600_drop_municipality_vote_goals` (backfill metas→estimativas onde a estimativa estava vazia): `expectedVotes` (pessimista/média/otimista) é a única série por cenário do município. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md).
**Recently resolved (2026-07-24):** **E4R** Import único da planilha de projeção — `pnpm db:seed:projecao` (`scripts/import-projecao.mjs`) lê o xlsx canônico em `docs/sheets/`, faz match via `canonicalizeMunicipalityName` + `municipalityCatalog`, e **sempre sobrescreve** `municipality.expectedVotes` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). **Fase 2 (mesmo dia, decisão do produto — não é mais zero PII):** o seed também importa as colunas de estratégia, com PRIORITÁRIAS sobrepondo MAPA GERAL quando não-vazia — SITUAÇÃO→`politicalTrend` (QUEDA/MANTÉM/AUMENTO→desfavorável/neutra/favorável, com nota de proveniência), DOBRADINHAS→`stateDeputy`+`stateDeputies` (união; célula crua em `dobradinhaNotes`), LIDERANÇAS→`contact` name-only **sem telefone** + `leadership` (dedup estadual por slug do nome; `Contact.phone` virou opcional via migration `20260724_175500_contact_phone_optional`, UI continua exigindo), ASSESSOR RESPONSÁVEL→`campaignUser`+`advisors` (união; Edizio=coordinator, Solla=candidate — `candidate` agora é elegível como assessor em `eligibleCampaignStaffWhere`; novos usuários com email `<slug>@planilha.invalid` + senha aleatória, sem login até um admin trocar), ENCAMINHAMENTOS→`nextSteps`, OBSERVAÇÃO→`strengths`/`risks` (classificação curada por slug). Relações são união (nunca removem vínculos da UI); re-run idempotente; nomes de atores políticos entraram sem trilha de consentimento — regularizar com a assessoria jurídica. No mesmo dia, o grupo duplicado `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido** — collection, zod, view models, form/card e utilitário `voteGoals.ts` — via migration `20260724_133600_drop_municipality_vote_goals` (backfill metas→estimativas onde a estimativa estava vazia): `expectedVotes` (pessimista/média/otimista) é a única série por cenário do município. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md).
**Recently resolved (2026-07-23):** **A10** Cenários de estimativa (pessimista/média/otimista) — `votePledge.estimatedVotes` e `municipality.expectedVotes` viram groups (`pessimistic|central|optimistic`); migration `20260723_124200_add_vote_estimate_scenarios` (backfill scalar→`central`); agregação por cenário em `votePledgeData` com default `central`; mapa 2026 com `values2026ByScenario` + seletor local; lista/overview/dashboard com média + faixa secundária; liderança continua com um `declaredVotes` sem ver estimativas. Plano: [`docs/plans/cenarios-estimativa-votos.md`](docs/plans/cenarios-estimativa-votos.md). Desbloqueia **E8**.

View file

@ -1,7 +1,7 @@
# E4R — Import único da planilha de projeção (seed de estratégia, local e produção)
Status: entregue em código (2026-07-24)
Atualizado em: 2026-07-24 (alvo mudou para `expectedVotes` — "Metas" removidas do app; overwrite-always; SheetJS; seed local 189 estimativas / 50 alta)
Status: entregue em código (2026-07-24; fase 2 — colunas de estratégia — no mesmo dia)
Atualizado em: 2026-07-24 (fase 2: SITUAÇÃO/DOBRADINHAS/LIDERANÇAS/ASSESSOR/ENCAMINHAMENTOS/OBSERVAÇÃO + override PRIORITÁRIAS; antes: alvo mudou para `expectedVotes`, overwrite-always, SheetJS, seed local 189 estimativas / 50 alta)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (E4R)
Impeccable: n/a — script/CLI sem superfície de UI (relatório em stdout)
Appetite: ~1 dia eng (parser + mapeamento + dry-run + runbook de produção)
@ -11,6 +11,17 @@ Revisão 2026-07-24 (a): idempotência mudou de “só campos vazios” para **s
Revisão 2026-07-24 (b) — **alvo é `expectedVotes`, não `voteGoals`:** decisão de produto — "Meta Bom/Regular/Mínimo" duplicava o conceito de votos estimados; a campanha trabalha com UMA série por cenário. O grupo `municipality.voteGoals` foi **removido do app** (migration `20260724_133600_drop_municipality_vote_goals`, com backfill metas→estimativas onde estimativa estava vazia) e o seed passou a gravar `municipality.expectedVotes` com o mapeamento **Bom → otimista, Regular → média (`central`), Mínimo → pessimista**. A ordem é validada com `getVoteEstimateOrderViolation` (pessimista ≤ média ≤ otimista) e a UI exibe tudo no card "Votos estimados".
Revisão 2026-07-24 (c) — **fase 2 entregue: colunas de estratégia** (decisão explícita do produto em 2026-07-24, antecipando o gatilho "lote jurídico" — nomes de atores políticos entram em produção com entidades name-only; regularizar trilha de consentimento com a assessoria depois). O mesmo `pnpm db:seed:projecao` agora também importa, com **PRIORITÁRIAS sobrepondo MAPA GERAL** em SITUAÇÃO/LIDERANÇAS/ASSESSOR quando a célula é não-vazia:
- **SITUAÇÃO → `municipality.politicalTrend`**: QUEDA→`desfavoravel`, MANTÉM→`neutra`, AUMENTO→`favoravel` (`parseSituationCell`; "NÃO DEFINIDA" não toca o campo). `note` guarda proveniência ("Importado da planilha de projeção (jul/2026) — SITUAÇÃO: …").
- **DOBRADINHAS → `stateDeputy` + `municipality.stateDeputies`** (união) e célula crua sempre em `dobradinhaNotes`. O nome do candidato ("Solla") em célula de dobradinha é descartado com relatório.
- **LIDERANÇAS → `contact` (name-only, sem telefone) + `leadership`** — dedup estadual por slug do nome (uma pessoa, N municípios; merges listados no dry-run). `Contact.phone` virou **opcional** na collection (migration `20260724_175500_contact_phone_optional`; formulários/zod continuam exigindo) e os hooks de invariante de telefone pulam contatos sem phone.
- **ASSESSOR RESPONSÁVEL → `campaignUser` + `municipality.advisors`** (união): "Edizio" → `coordinator`, "Solla"/"Jorge Solla" → `candidate` (o papel `candidate` passou a ser elegível em `eligibleCampaignStaffWhere` — decisão do produto), demais → `advisor`. Usuários novos nascem com email placeholder `<slug>@planilha.invalid` (TLD reservado, não roteável) + senha aleatória — não logam até um admin trocar as credenciais.
- **ENCAMINHAMENTOS → `nextSteps`** (overwrite quando não-vazio); **OBSERVAÇÃO → `strengths`/`risks`** com classificação curada por slug (`OBSERVATION_TARGET_BY_SLUG`; 4 células hoje: Brejões→força; Camaçari/Inhambupe/Laje→risco), idempotente por texto exato.
- **Parsing de nomes** (`splitNameCell`, puro, com testes): separadores `,`/`;`/`|`/barra/" e "/ponto-de-frase (honoríficos "Dr./Dra./Sr./Sra./Prof./Sec." protegidos); parentéticos extraídos; ruído descartado com relatório (traços, `?`, frases de ação "VER COM…/CONFIRMAR…/Construindo…", coletivos "pessoal do PT/grupo de ACS/Associação…", papéis sem nome "Prefeito/vereadores/Ex-vice…"). Aliases curados no script: staff `mariana→Marianna`, `joao→João Lúcio`, `caio→Caio Cesar`; dobradinha `galo→Marcelino Galo`, `angelo→Ângelo Almeida`, `fatima→Fátima Nunes`, `rowena→Rowenna`, `osni→Osny`. Nome canônico = variante mais longa (desempate: inicial maiúscula → frequência → acentos).
- **Semântica**: relações são **união** (nunca removem vínculos feitos na UI); textos definidos na planilha sobrescrevem; célula vazia nunca limpa; re-run é idempotente (delta=0 verificado localmente). Salvador continua **pulado para todas as colunas** (decisão do usuário; estratégia por ZE entra via UI).
- Seed local verificado: 416 casadas, 205 municípios com escrita, 26 usuários (24 assessores + Edizio coordinator + Jorge Solla candidate), 40 dobradinhas, 288 lideranças; re-run delta=0.
## Contexto
A sessão real com o Coordenador Geral (2026-07-23 — [CUSTOMER.md](../CUSTOMER.md)) confirmou O5 com força máxima: a planilha de projeção é a fonte de verdade da mesa ("eu acordo todos os dias lendo o nosso mapa") e ele se comprometeu a enviá-la — as versões mais recentes já estão versionadas em `docs/sheets/`. O corte E4 (2026-07-19, [mapa-projecao-municipios.md](mapa-projecao-municipios.md): "dados estratégicos via UI, sem script") foi decidido **antes** dessa evidência; preencher ~240 municípios com metas à mão na semana de onboarding não acontece, e o Big Hire depende de "seeded picture" (CUSTOMER.md). Decisão de produto de 2026-07-24: **seed único e assistido via script**; import automático/recorrente continua fora de escopo (atualização contínua segue via UI — o produto é o registro vivo, não espelho de planilha).
@ -37,8 +48,8 @@ Divergências A×B no MAPA GERAL (9 linhas: Brejões, Catu, Iaçu, Ipiaú, Jagua
## Decisões travadas
- **v1 importa apenas números/enums (`expectedVotes`, `priority`).** Colunas com nomes (LIDERANÇAS, ASSESSOR RESPONSÁVEL, DOBRADINHAS, ENCAMINHAMENTOS, OBSERVAÇÃO) ficam para fase 2, pós-lote jurídico — nome de ator político é dado pessoal que revela opinião política (LGPD art. 11); destino (CRM `leadership` × nota staff-only) se decide com a assessoria. **Rejeitado:** importar tudo com access staff-only (antecipa exatamente o risco que a Onda 0 segura); campo novo `networkNotes` (schema por dado que ainda não pode entrar).
- **SITUAÇÃO e VOTOS não são importados.** Tendência é derivada do TSE (`computeVoteTrend` — decisão E2 mantida); votos históricos vêm de `electionTally`/artefato. A planilha só manda no que é julgamento humano: metas e prioridade. **Rejeitado:** persistir a SITUAÇÃO da planilha (duplicaria a derivação com dado defasado).
- ~~**v1 importa apenas números/enums (`expectedVotes`, `priority`).**~~ **Superado pela revisão (c):** o produto decidiu importar as colunas com nomes em 2026-07-24, com entidades name-only e trilha de consentimento a regularizar com a assessoria. (Racional original: nome de ator político é dado pessoal que revela opinião política — LGPD art. 11.)
- ~~**SITUAÇÃO e VOTOS não são importados.**~~ **Superado pela revisão (c) para SITUAÇÃO** (vira `politicalTrend`, o campo de julgamento humano da coordenação — a tendência derivada do TSE via `computeVoteTrend` segue intacta no card de baseline). VOTOS históricos continuam fora (vêm de `electionTally`/artefato).
- **Salvador (linha única, metas 30k/27k/25k) é pulado com relatório explícito.** O catálogo tem 19 Municípios-zona e a planilha não distribui; inventar rateio viola "não estimar o que não tem dado" (falsa precisão). Metas de zona entram via UI pelo coordenador. **Rejeitado:** rateio proporcional ao voto por zona (número inventado com cara de número).
- **Arquivo A é o canônico de estratégia; B é referência.** Mais recente nos 9 conflitos e corroborado pela entrevista (Jaguaquara). O script aceita `--file` para apontar planilha nova se chegar versão mais atual.
- **`priority`: `alta` → `alta`; `Baixa`/vazio → `normal`.** Cross-check com a aba PRIORITÁRIAS (conjunto alfabético de 50, não ranking): prioritária sem `alta` no MAPA GERAL = warning no relatório.
@ -79,7 +90,7 @@ flowchart LR
## Não escopo
- Import recorrente/automático (fora de escopo do roadmap — atualização contínua via UI); colunas com nomes (fase 2 pós-lote jurídico); Salvador por zona/bairro (UI manual; drill de bairro segue como E5 futuro); criação de `leadership`/`Contact` a partir da planilha (CRM real só com Consent).
- Import recorrente/automático (fora de escopo do roadmap — atualização contínua via UI); Salvador por zona/bairro (UI manual, inclusive para as colunas de estratégia; drill de bairro segue como E5 futuro). ~~Colunas com nomes / criação de `leadership`+`Contact`~~ entregues na revisão (c).
## Rabbit holes

View file

@ -1,13 +1,21 @@
/**
* E4R — Import único da planilha de projeção → municipality.expectedVotes + priority.
* E4R — Import da planilha de projeção → municipality (estimativas + colunas de estratégia).
*
* Provenance:
* - Canonical strategy file: docs/sheets/Mapa projeção de votos Solla 2026.xlsx (MAPA GERAL)
* - Reference (Salvador / A×B diff): docs/sheets/Mapa_projecao_votos_Solla_2026.xlsx
* - Sheet scenarios map Bom → otimista, Regular → média (central), Mínimo → pessimista.
* - Writes only numbers/enums (zero PII). Never creates Contact/leadership.
* - Always overwrites expectedVotes + priority on matched rows (re-seed when the mesa
* sends a newer sheet over an already-populated DB). Does not touch lastUpdateAt.
* - Fase 1 (E4R): expectedVotes + priority (overwrite nas linhas casadas).
* - Fase 2 (2026-07-24): SITUAÇÃO → politicalTrend; DOBRADINHAS → stateDeputy + stateDeputies
* (+ dobradinhaNotes com a célula crua); LIDERANÇAS → contact (sem telefone) + leadership;
* ASSESSOR RESPONSÁVEL → campaignUser + municipality.advisors; ENCAMINHAMENTOS → nextSteps;
* OBSERVAÇÃO → strengths/risks (classificação curada por slug). A aba PRIORITÁRIAS sobrepõe
* SITUAÇÃO/LIDERANÇAS/ASSESSOR quando a célula é não-vazia. Entidades nascem name-only
* (usuários sem email/username/credenciais; contatos sem telefone) — ajuste manual depois.
* - Relações (advisors, stateDeputies, municípios da liderança) são UNIÃO (nunca removem);
* campos de texto definidos na planilha sobrescrevem; nada é limpo quando a célula é vazia.
* - Salvador (19 municípios-zona) é pulado para TODAS as colunas (relatório explícito).
* - Não toca lastUpdateAt.
*
* Safety: refuses non-local DATABASE_URL unless ALLOW_REMOTE_DB=true.
*
@ -25,6 +33,7 @@
* --reference <path> Override reference workbook for A×B diff
*/
import { randomBytes } from 'node:crypto'
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -58,7 +67,18 @@ const SALVADOR_CITY = 'Salvador'
* votes2022: number | null
* expectationRaw: string | null
* priorityRaw: string | null
* situationRaw: string | null
* dobradinhasRaw: string | null
* leadershipsRaw: string | null
* advisorsRaw: string | null
* nextStepsRaw: string | null
* observationRaw: string | null
* }} ProjectionSheetRow
* @typedef {{
* situationRaw: string | null
* leadershipsRaw: string | null
* advisorsRaw: string | null
* }} PrioritariaRow
* @typedef {{ optimistic?: number | null, central?: number | null, pessimistic?: number | null } | null | undefined} DbExpectedVotes
*/
@ -165,6 +185,12 @@ const readMapaGeralRows = (workbook, parseSheetNumber, normalizeKey) => {
const votes2014Col = indexByLabel.get('VOTOS 2014')
const votes2018Col = indexByLabel.get('VOTOS 2018')
const votes2022Col = indexByLabel.get('VOTOS 2022')
const situationCol = indexByLabel.get('SITUAÇÃO')
const dobradinhasCol = indexByLabel.get('DOBRADINHAS')
const leadershipsCol = indexByLabel.get('LIDERANÇAS')
const advisorsCol = indexByLabel.get('ASSESSOR RESPONSÁVEL')
const nextStepsCol = indexByLabel.get('ENCAMINHAMENTOS')
const observationCol = indexByLabel.get('OBSERVAÇÃO')
/** @type {Map<string, ProjectionSheetRow>} */
const byKey = new Map()
@ -186,6 +212,12 @@ const readMapaGeralRows = (workbook, parseSheetNumber, normalizeKey) => {
votes2022: parseSheetNumber(row[votes2022Col]),
expectationRaw: cellString(row[expectationCol]),
priorityRaw: cellString(row[priorityCol]),
situationRaw: situationCol === undefined ? null : cellString(row[situationCol]),
dobradinhasRaw: dobradinhasCol === undefined ? null : cellString(row[dobradinhasCol]),
leadershipsRaw: leadershipsCol === undefined ? null : cellString(row[leadershipsCol]),
advisorsRaw: advisorsCol === undefined ? null : cellString(row[advisorsCol]),
nextStepsRaw: nextStepsCol === undefined ? null : cellString(row[nextStepsCol]),
observationRaw: observationCol === undefined ? null : cellString(row[observationCol]),
})
}
@ -193,30 +225,40 @@ const readMapaGeralRows = (workbook, parseSheetNumber, normalizeKey) => {
}
/**
* PRIORITÁRIAS rows override MAPA GERAL for SITUAÇÃO/LIDERANÇAS/ASSESSOR when non-empty.
* @param {import('xlsx').WorkBook} workbook
* @param {(value: string) => string} normalizeKey
* @returns {Map<string, PrioritariaRow>}
*/
const readPrioritariaNames = (workbook, normalizeKey) => {
const readPrioritariaRows = (workbook, normalizeKey) => {
const matrix = sheetMatrix(workbook, PRIORITARIAS)
const { rowIndex, indexByLabel } = findHeaderIndex(matrix, ['MUNICÍPIO'])
const municipalityCol = indexByLabel.get('MUNICÍPIO')
/** @type {Set<string>} */
const names = new Set()
const situationCol = indexByLabel.get('SITUAÇÃO')
const leadershipsCol = indexByLabel.get('LIDERANÇAS')
const advisorsCol = indexByLabel.get('ASSESSOR RESPONSÁVEL')
/** @type {Map<string, PrioritariaRow>} */
const byKey = new Map()
for (let i = rowIndex + 1; i < matrix.length; i += 1) {
const row = matrix[i]
if (!Array.isArray(row)) continue
const label = cellString(row[municipalityCol])
if (!label || label.toUpperCase() === 'MUNICÍPIO') continue
names.add(normalizeKey(label))
byKey.set(normalizeKey(label), {
situationRaw: situationCol === undefined ? null : cellString(row[situationCol]),
leadershipsRaw: leadershipsCol === undefined ? null : cellString(row[leadershipsCol]),
advisorsRaw: advisorsCol === undefined ? null : cellString(row[advisorsCol]),
})
}
return names
return byKey
}
assertLocalDatabase(
'seed:projecao',
'This script overwrites municipality expectedVotes/priority from the projection sheet.',
'This script overwrites municipality strategy fields from the projection sheet.',
)
const options = parseArgs(process.argv.slice(2))
@ -232,9 +274,8 @@ const { canonicalizeMunicipalityName, UnknownMunicipalityError, normalizeMunicip
const { municipalityCatalogEntriesForCity } = await import('../src/lib/municipalityCatalog.ts')
const { getMunicipalityFederalBaseline } = await import('../src/lib/bahiaElectionAggregates.ts')
const { territoryForCity } = await import('../src/lib/bahiaTerritories.ts')
const { parseExpectationCell, mapSheetPriority, parseSheetNumber } = await import(
'../src/lib/projectionSheetParse.ts'
)
const { parseExpectationCell, mapSheetPriority, parseSheetNumber, parseSituationCell, splitNameCell } =
await import('../src/lib/projectionSheetParse.ts')
const {
getVoteEstimateOrderViolation,
toVoteEstimateScenarioViewModel,
@ -243,15 +284,131 @@ const {
hasAnyVoteEstimate,
} = await import('../src/utilities/voteEstimate.ts')
const { withPayloadTransaction } = await import('../src/utilities/payloadTransaction.ts')
const { slugify } = await import('../src/utilities/slug.ts')
/** @param {import('../src/utilities/voteEstimate.ts').VoteEstimateScenarioViewModel} estimates */
const formatEstimates = (estimates) => {
if (!hasAnyVoteEstimate(estimates)) return '(vazio)'
return `${voteEstimateScenarioLabels.optimistic}:${estimates.optimistic ?? '—'} | ${voteEstimateScenarioLabels.central}:${estimates.central ?? '—'} | ${voteEstimateScenarioLabels.pessimistic}:${estimates.pessimistic ?? '—'}`
}
// ---------------------------------------------------------------------------
// Curated name resolution (adjust here when the mesa sends corrections)
// ---------------------------------------------------------------------------
/** Sheet spelling variants → canonical staff slug ("Edizio" é o CG; "Solla" é o candidato). */
const STAFF_SLUG_ALIASES = {
mariana: 'marianna',
joao: 'joao-lucio',
caio: 'caio-cesar',
solla: 'jorge-solla',
}
const COORDINATOR_SLUG = 'edizio'
const CANDIDATE_SLUG = 'jorge-solla'
const CANDIDATE_DISPLAY_NAME = 'Jorge Solla'
/** Sheet spelling variants → canonical state-deputy slug. */
const DEPUTY_SLUG_ALIASES = {
galo: 'marcelino-galo',
angelo: 'angelo-almeida',
fatima: 'fatima-nunes',
rowena: 'rowenna',
osni: 'osny',
}
/** The candidate himself shows up in DOBRADINHAS/LIDERANÇAS cells — never an entity there. */
const CANDIDATE_NAME_SLUGS = new Set(['solla', 'sola', 'jorge-solla'])
/**
* OBSERVAÇÃO → strengths ("Forças") or risks ("Riscos"), curated per municipality slug
* (only 4 filled cells in the current sheet; classify new ones here when they appear).
*/
const OBSERVATION_TARGET_BY_SLUG = {
brejoes: 'strengths', // "Prefeito se aproximou de Solla" — sinal positivo
camacari: 'risks', // "Resolver demanda de Mutakani" — pendência aberta
inhambupe: 'risks', // articulação com ex-prefeito ainda em construção
laje: 'risks', // diálogo a acompanhar
}
const trendNoteFor = (situationRaw) =>
`Importado da planilha de projeção (jul/2026) — SITUAÇÃO: ${situationRaw}`
const staffSlugFor = (name) => {
const slug = slugify(name)
return STAFF_SLUG_ALIASES[slug] ?? slug
}
const deputySlugFor = (name) => {
const slug = slugify(name)
return DEPUTY_SLUG_ALIASES[slug] ?? slug
}
/**
* @typedef {{ variants: Map<string, number>, municipalityLabels: Set<string>, municipalityIds: Set<number>, cities: Set<string> }} RosterEntry
*/
/** @returns {RosterEntry} */
const emptyRosterEntry = () => ({
variants: new Map(),
municipalityLabels: new Set(),
municipalityIds: new Set(),
cities: new Set(),
})
/**
* @param {Map<string, RosterEntry>} roster
* @param {string} slug
* @param {string} rawName
* @param {{ label: string, id: number, city: string }} municipality
*/
const addRosterEntry = (roster, slug, rawName, municipality) => {
let entry = roster.get(slug)
if (!entry) {
entry = emptyRosterEntry()
roster.set(slug, entry)
}
entry.variants.set(rawName, (entry.variants.get(rawName) ?? 0) + 1)
entry.municipalityLabels.add(municipality.label)
entry.municipalityIds.add(municipality.id)
entry.cities.add(municipality.city)
}
/**
* Longest variant wins (full names beat nicknames); ties break by capitalized first
* letter, then frequency, then accented spelling, then first-seen.
*/
const canonicalRosterName = (entry) => {
const score = (name, count) => [
name.length,
/^\p{Lu}/u.test(name) ? 1 : 0,
count,
(name.match(/[À-ÿ]/g) ?? []).length,
]
const beats = (a, b) => {
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return a[i] > b[i]
}
return false // full tie → keep the first-seen variant
}
let best = null
let bestScore = null
for (const [name, count] of entry.variants) {
const nameScore = score(name, count)
if (best === null || beats(nameScore, bestScore)) {
best = name
bestScore = nameScore
}
}
return best ?? ''
}
const formatVariants = (entry) =>
[...entry.variants.entries()].map(([name, count]) => `${name}×${count}`).join(', ')
const canonicalBook = XLSX.readFile(options.file, { cellDates: false })
const canonicalRows = readMapaGeralRows(canonicalBook, parseSheetNumber, normalizeMunicipalityKey)
const prioritarias = readPrioritariaNames(canonicalBook, normalizeMunicipalityKey)
const prioritariaRows = readPrioritariaRows(canonicalBook, normalizeMunicipalityKey)
const prioritarias = new Set(prioritariaRows.keys())
/** @type {Map<string, ProjectionSheetRow> | null} */
let referenceRows = null
@ -292,19 +449,21 @@ const municipalityDocs = await payload.find({
kind: true,
priority: true,
expectedVotes: true,
advisors: true,
stateDeputies: true,
dobradinhaNotes: true,
nextSteps: true,
strengths: true,
risks: true,
politicalTrend: true,
},
})
/** @type {Map<string, { id: number, slug: string, priority: string | null, expectedVotes: DbExpectedVotes }>} */
/** @type {Map<string, (typeof municipalityDocs.docs)[number]>} */
const docsBySlug = new Map()
for (const doc of municipalityDocs.docs) {
if (typeof doc.slug === 'string') {
docsBySlug.set(doc.slug, {
id: doc.id,
slug: doc.slug,
priority: doc.priority ?? null,
expectedVotes: doc.expectedVotes ?? null,
})
docsBySlug.set(doc.slug, doc)
}
}
@ -314,6 +473,19 @@ const unmatched = []
const parseErrors = []
const warnings = []
const axbDiffs = []
/** @type {Array<{ label: string, column: string, segment: string }>} */
const skippedSegments = []
/** @type {Array<{ label: string, column: string, prior: string, geral: string }>} */
const prioritariaOverrides = []
/** @type {Map<string, RosterEntry>} */
const staffRoster = new Map()
/** @type {Map<string, RosterEntry>} */
const deputyRoster = new Map()
/** @type {Map<string, RosterEntry>} */
const leadershipRoster = new Map()
const normalizeCellComparison = (value) => value.replace(/\s+/g, ' ').trim().toLowerCase()
for (const sheetRow of canonicalRows.values()) {
let city
@ -330,11 +502,23 @@ for (const sheetRow of canonicalRows.values()) {
throw error
}
const sheetKey = normalizeMunicipalityKey(sheetRow.municipalityLabel)
const prioritariaRow = prioritariaRows.get(sheetKey) ?? null
if (city === SALVADOR_CITY) {
skippedSalvador.push({
label: sheetRow.municipalityLabel,
expectationRaw: sheetRow.expectationRaw,
priorityRaw: sheetRow.priorityRaw,
hasStrategy: Boolean(
sheetRow.situationRaw ||
sheetRow.dobradinhasRaw ||
sheetRow.leadershipsRaw ||
sheetRow.advisorsRaw ||
sheetRow.nextStepsRaw ||
sheetRow.observationRaw ||
prioritariaRow,
),
})
continue
}
@ -360,29 +544,25 @@ for (const sheetRow of canonicalRows.values()) {
continue
}
const expectedVotes = parseExpectationCell(sheetRow.expectationRaw)
if (!expectedVotes) {
if (sheetRow.expectationRaw) {
parseErrors.push({
label: sheetRow.municipalityLabel,
slug: catalogEntry.slug,
raw: sheetRow.expectationRaw,
})
}
continue
// --- Estimativas (fase 1, comportamento preservado) -----------------------
let expectedVotes = parseExpectationCell(sheetRow.expectationRaw)
if (!expectedVotes && sheetRow.expectationRaw) {
parseErrors.push({
label: sheetRow.municipalityLabel,
slug: catalogEntry.slug,
raw: sheetRow.expectationRaw,
})
}
if (getVoteEstimateOrderViolation(expectedVotes)) {
if (expectedVotes && getVoteEstimateOrderViolation(expectedVotes)) {
parseErrors.push({
label: sheetRow.municipalityLabel,
slug: catalogEntry.slug,
raw: `${sheetRow.expectationRaw} (ordem Otimista ≥ Média ≥ Pessimista inválida)`,
})
continue
expectedVotes = null
}
const priority = mapSheetPriority(sheetRow.priorityRaw)
const sheetKey = normalizeMunicipalityKey(sheetRow.municipalityLabel)
if (prioritarias.has(sheetKey) && priority !== 'alta') {
warnings.push(
@ -418,7 +598,7 @@ for (const sheetRow of canonicalRows.values()) {
)
}
if (referenceRows) {
if (expectedVotes && referenceRows) {
const ref = referenceRows.get(sheetKey)
if (ref) {
const refEstimates = parseExpectationCell(ref.expectationRaw)
@ -440,29 +620,423 @@ for (const sheetRow of canonicalRows.values()) {
}
}
matched.push({ doc, expectedVotes, priority })
// --- Estratégia (fase 2) ---------------------------------------------------
const resolveOverride = (column, priorRaw, geralRaw) => {
if (priorRaw != null && geralRaw != null) {
if (normalizeCellComparison(priorRaw) !== normalizeCellComparison(geralRaw)) {
prioritariaOverrides.push({
label: sheetRow.municipalityLabel,
column,
prior: priorRaw,
geral: geralRaw,
})
}
}
return priorRaw ?? geralRaw
}
const situationRaw = resolveOverride(
'SITUAÇÃO',
prioritariaRow?.situationRaw ?? null,
sheetRow.situationRaw,
)
const leadershipsRaw = resolveOverride(
'LIDERANÇAS',
prioritariaRow?.leadershipsRaw ?? null,
sheetRow.leadershipsRaw,
)
const advisorsRaw = resolveOverride(
'ASSESSOR RESPONSÁVEL',
prioritariaRow?.advisorsRaw ?? null,
sheetRow.advisorsRaw,
)
const trendStatus = parseSituationCell(situationRaw)
const municipalityRef = { label: sheetRow.municipalityLabel, id: doc.id, city }
const advisorsSplit = splitNameCell(advisorsRaw)
const advisorSlugs = []
for (const name of advisorsSplit.names) {
const slug = staffSlugFor(name)
if (!advisorSlugs.includes(slug)) advisorSlugs.push(slug)
addRosterEntry(staffRoster, slug, name, municipalityRef)
}
for (const segment of advisorsSplit.skipped) {
skippedSegments.push({ label: sheetRow.municipalityLabel, column: 'ASSESSOR', segment })
}
const deputiesSplit = splitNameCell(sheetRow.dobradinhasRaw)
const deputySlugs = []
for (const name of deputiesSplit.names) {
const slug = deputySlugFor(name)
if (CANDIDATE_NAME_SLUGS.has(slug)) {
skippedSegments.push({
label: sheetRow.municipalityLabel,
column: 'DOBRADINHAS',
segment: `${name} (candidato — não vira dobradinha)`,
})
continue
}
if (!deputySlugs.includes(slug)) deputySlugs.push(slug)
addRosterEntry(deputyRoster, slug, name, municipalityRef)
}
for (const segment of deputiesSplit.skipped) {
skippedSegments.push({ label: sheetRow.municipalityLabel, column: 'DOBRADINHAS', segment })
}
const leadershipsSplit = splitNameCell(leadershipsRaw)
for (const name of leadershipsSplit.names) {
const slug = slugify(name)
if (CANDIDATE_NAME_SLUGS.has(slug)) {
skippedSegments.push({
label: sheetRow.municipalityLabel,
column: 'LIDERANÇAS',
segment: `${name} (candidato — não vira liderança)`,
})
continue
}
addRosterEntry(leadershipRoster, slug, name, municipalityRef)
}
for (const segment of leadershipsSplit.skipped) {
skippedSegments.push({ label: sheetRow.municipalityLabel, column: 'LIDERANÇAS', segment })
}
const observationTarget = sheetRow.observationRaw
? (OBSERVATION_TARGET_BY_SLUG[catalogEntry.slug] ?? null)
: null
if (sheetRow.observationRaw && !observationTarget) {
warnings.push(
`OBSERVAÇÃO sem classificação Forças/Riscos para ${sheetRow.municipalityLabel} (${catalogEntry.slug}) — adicione em OBSERVATION_TARGET_BY_SLUG: ${JSON.stringify(sheetRow.observationRaw)}`,
)
}
matched.push({
doc,
label: sheetRow.municipalityLabel,
expectedVotes,
priority,
trendStatus,
situationRaw,
advisorSlugs,
deputySlugs,
dobradinhasRaw: sheetRow.dobradinhasRaw,
nextStepsRaw: sheetRow.nextStepsRaw,
observationRaw: sheetRow.observationRaw,
observationTarget,
})
}
// ---------------------------------------------------------------------------
// Entity plans (matched against the current database, created inside the tx)
// ---------------------------------------------------------------------------
const existingUsers = await payload.find({
collection: 'campaignUser',
depth: 0,
limit: 5000,
pagination: false,
overrideAccess: true,
select: { name: true, role: true },
})
const usersBySlug = new Map()
for (const user of existingUsers.docs) {
const slug = slugify(String(user.name ?? ''))
if (!slug) continue
if (usersBySlug.has(slug)) {
warnings.push(
`Usuários duplicados com o nome "${user.name}" no banco — usando o id ${usersBySlug.get(slug).id} para o match.`,
)
continue
}
usersBySlug.set(slug, user)
}
const existingCoordinators = existingUsers.docs.filter((user) => user.role === 'coordinator')
const existingCandidates = existingUsers.docs.filter((user) => user.role === 'candidate')
const staffPlans = new Map()
for (const [slug, entry] of staffRoster) {
const role =
slug === COORDINATOR_SLUG ? 'coordinator' : slug === CANDIDATE_SLUG ? 'candidate' : 'advisor'
const name = slug === CANDIDATE_SLUG ? CANDIDATE_DISPLAY_NAME : canonicalRosterName(entry)
let existing = usersBySlug.get(slug) ?? null
if (existing && existing.role === 'leader') {
warnings.push(
`Usuário existente "${existing.name}" (id ${existing.id}) tem papel Liderança — não pode ser assessor; será criado um novo usuário "${name}".`,
)
existing = null
}
if (!existing && role === 'coordinator' && existingCoordinators.length === 1) {
existing = existingCoordinators[0]
}
if (!existing && role === 'candidate' && existingCandidates.length === 1) {
existing = existingCandidates[0]
}
staffPlans.set(slug, {
slug,
name,
role,
existing,
userId: existing ? existing.id : null,
entry,
})
}
const existingDeputies = await payload.find({
collection: 'stateDeputy',
depth: 0,
limit: 5000,
pagination: false,
overrideAccess: true,
select: { name: true, slug: true },
})
const deputiesBySlug = new Map(existingDeputies.docs.map((deputy) => [deputy.slug, deputy]))
const deputyPlans = new Map()
for (const [slug, entry] of deputyRoster) {
const existing = deputiesBySlug.get(slug) ?? null
deputyPlans.set(slug, {
slug,
name: canonicalRosterName(entry),
existing,
deputyId: existing ? existing.id : null,
entry,
})
}
const existingLeaderships = await payload.find({
collection: 'leadership',
depth: 1,
limit: 5000,
pagination: false,
overrideAccess: true,
select: { contact: true, municipalities: true },
})
const relationId = (value) => (typeof value === 'object' && value !== null ? value.id : value)
const leadershipsByContactSlug = new Map()
for (const leadership of existingLeaderships.docs) {
const contact = leadership.contact
const contactName = typeof contact === 'object' && contact !== null ? contact.name : null
if (typeof contactName !== 'string') continue
const slug = slugify(contactName)
if (!slug || leadershipsByContactSlug.has(slug)) continue
leadershipsByContactSlug.set(slug, {
id: leadership.id,
contactName,
municipalityIds: (leadership.municipalities ?? []).map(relationId).filter((id) => id != null),
})
}
const leadershipPlans = new Map()
for (const [slug, entry] of leadershipRoster) {
const existing = leadershipsByContactSlug.get(slug) ?? null
const municipalityIds = [...entry.municipalityIds]
const missingIds = existing
? municipalityIds.filter((id) => !existing.municipalityIds.includes(id))
: municipalityIds
leadershipPlans.set(slug, {
slug,
name: canonicalRosterName(entry),
existing,
municipalityIds,
missingIds,
city: entry.cities.size === 1 ? [...entry.cities][0] : null,
entry,
})
}
// ---------------------------------------------------------------------------
// Municipality write plans (delta vs current DB state)
// ---------------------------------------------------------------------------
const uniqueUnion = (current, additions) => {
const merged = [...new Set([...current, ...additions])]
return merged.length === current.length ? null : merged
}
/** Builds the municipality update payload for a matched row (null = nothing to write). */
const buildMunicipalityData = (row) => {
const data = {}
if (row.expectedVotes) {
const currentEstimates = toVoteEstimateScenarioViewModel(row.doc.expectedVotes)
const nextEstimates = toVoteEstimateScenarioViewModel(row.expectedVotes)
const currentPriority = row.doc.priority === 'alta' ? 'alta' : 'normal'
if (!voteEstimatesEqual(currentEstimates, nextEstimates) || currentPriority !== row.priority) {
data.expectedVotes = row.expectedVotes
data.priority = row.priority
}
}
if (row.trendStatus) {
const note = trendNoteFor(row.situationRaw)
const currentTrend = row.doc.politicalTrend ?? {}
if (currentTrend.status !== row.trendStatus || (currentTrend.note ?? '') !== note) {
data.politicalTrend = { status: row.trendStatus, note }
}
}
if (row.advisorSlugs.length > 0) {
const advisorIds = row.advisorSlugs
.map((slug) => staffPlans.get(slug)?.userId)
.filter((id) => id != null)
const merged = uniqueUnion(row.doc.advisors ?? [], advisorIds)
if (merged) data.advisors = merged
}
if (row.deputySlugs.length > 0) {
const deputyIds = row.deputySlugs
.map((slug) => deputyPlans.get(slug)?.deputyId)
.filter((id) => id != null)
const merged = uniqueUnion(row.doc.stateDeputies ?? [], deputyIds)
if (merged) data.stateDeputies = merged
}
if (row.dobradinhasRaw && row.dobradinhasRaw !== (row.doc.dobradinhaNotes ?? '')) {
data.dobradinhaNotes = row.dobradinhasRaw
}
if (row.nextStepsRaw && row.nextStepsRaw !== (row.doc.nextSteps ?? '')) {
data.nextSteps = row.nextStepsRaw
}
if (row.observationTarget && row.observationRaw) {
const current = row.doc[row.observationTarget] ?? []
const text = row.observationRaw.trim()
if (!current.some((item) => (item.text ?? '').trim() === text)) {
data[row.observationTarget] = [
...current.map((item) => ({ id: item.id, text: item.text })),
{ text },
]
}
}
return Object.keys(data).length > 0 ? data : null
}
/**
* Pre-transaction dry-run delta: relationship ids for entities that will only exist after
* creation are unknown here, so "will add" is inferred from the plans instead.
*/
const planSummaryFor = (row) => {
const fields = []
const data = buildMunicipalityData(row)
if (data?.expectedVotes) fields.push('estimativas/prioridade')
if (data?.politicalTrend) fields.push('tendência')
const advisorCreates = row.advisorSlugs.some((slug) => staffPlans.get(slug)?.userId == null)
if (data?.advisors || advisorCreates) fields.push('assessores')
const deputyCreates = row.deputySlugs.some((slug) => deputyPlans.get(slug)?.deputyId == null)
if (data?.stateDeputies || deputyCreates) fields.push('dobradinhas')
if (data?.dobradinhaNotes) fields.push('dobradinhas (notas)')
if (data?.nextSteps) fields.push('encaminhamentos')
if (data?.strengths) fields.push('forças')
if (data?.risks) fields.push('riscos')
return fields
}
// ---------------------------------------------------------------------------
// Report
// ---------------------------------------------------------------------------
const staffToCreate = [...staffPlans.values()].filter((plan) => plan.userId == null)
const staffMatched = [...staffPlans.values()].filter((plan) => plan.userId != null)
const deputiesToCreate = [...deputyPlans.values()].filter((plan) => plan.deputyId == null)
const leadershipsToCreate = [...leadershipPlans.values()].filter((plan) => !plan.existing)
const leadershipsToLink = [...leadershipPlans.values()].filter(
(plan) => plan.existing && plan.missingIds.length > 0,
)
const estimateRows = matched.filter((row) => row.expectedVotes)
const trendRows = matched.filter((row) => row.trendStatus)
const municipalityPlans = matched
.map((row) => ({ row, fields: planSummaryFor(row) }))
.filter((plan) => plan.fields.length > 0)
console.log('\n=== E4R import projeção — relatório ===')
console.log(`Arquivo canônico: ${options.file}`)
if (referenceRows) console.log(`Arquivo referência: ${options.reference}`)
console.log(
`Modo: ${options.dryRun ? 'DRY-RUN (sem escrita)' : 'ESCRITA (overwrite expectedVotes+priority)'}`,
`Modo: ${options.dryRun ? 'DRY-RUN (sem escrita)' : 'ESCRITA (estimativas + estratégia; união em relações)'}`,
)
console.log(`Linhas MAPA GERAL (com município): ${canonicalRows.size}`)
console.log(`Casadas com EXPECTATIVA parseável: ${matched.length}`)
console.log(`Casadas: ${matched.length} · com EXPECTATIVA parseável: ${estimateRows.length}`)
console.log(`Com SITUAÇÃO definida: ${trendRows.length}`)
console.log(`Salvador pulado: ${skippedSalvador.length}`)
console.log(`Não casadas: ${unmatched.length}`)
console.log(`EXPECTATIVA ilegível: ${parseErrors.length}`)
console.log(`Avisos: ${warnings.length}`)
console.log(`Divergências A×B: ${axbDiffs.length}`)
console.log(`Prioritárias na aba: ${prioritarias.size}`)
console.log(
`Assessores: ${staffPlans.size} (criar ${staffToCreate.length}, casados ${staffMatched.length})`,
)
console.log(
`Dobradinhas: ${deputyPlans.size} (criar ${deputiesToCreate.length}, casadas ${deputyPlans.size - deputiesToCreate.length})`,
)
console.log(
`Lideranças: ${leadershipPlans.size} (criar ${leadershipsToCreate.length}, vincular municípios em existentes ${leadershipsToLink.length})`,
)
console.log(`Segmentos descartados nas células de nomes: ${skippedSegments.length}`)
console.log(`Overrides PRIORITÁRIAS ≠ MAPA GERAL: ${prioritariaOverrides.length}`)
if (staffPlans.size > 0) {
console.log('\n— Assessores (coluna ASSESSOR RESPONSÁVEL) —')
for (const plan of [...staffPlans.values()].sort((a, b) => a.name.localeCompare(b.name))) {
const status = plan.existing
? `casa com usuário existente #${plan.existing.id} (${plan.existing.name}, ${plan.existing.role})`
: `criar (papel ${plan.role}, sem credenciais)`
const variants = plan.entry.variants.size > 1 ? ` · variantes: ${formatVariants(plan.entry)}` : ''
console.log(` ${plan.name}: ${status} · ${plan.entry.municipalityIds.size} município(s)${variants}`)
}
}
if (deputyPlans.size > 0) {
console.log('\n— Dobradinhas (deputados estaduais) —')
for (const plan of [...deputyPlans.values()].sort((a, b) => a.name.localeCompare(b.name))) {
const status = plan.existing ? `casa com dobradinha existente #${plan.existing.id}` : 'criar'
const variants = plan.entry.variants.size > 1 ? ` · variantes: ${formatVariants(plan.entry)}` : ''
console.log(` ${plan.name}: ${status} · ${plan.entry.municipalityIds.size} município(s)${variants}`)
}
}
if (leadershipPlans.size > 0) {
console.log('\n— Lideranças (nome-only; contato sem telefone) —')
const multiMunicipality = [...leadershipPlans.values()].filter(
(plan) => plan.municipalityIds.length > 1,
)
console.log(
` ${leadershipsToCreate.length} nova(s), ${leadershipsToLink.length} existente(s) ganham município(s); ${multiMunicipality.length} com mais de um município (merge por nome — revisar):`,
)
for (const plan of multiMunicipality.sort((a, b) => a.name.localeCompare(b.name))) {
console.log(` ${plan.name}: ${[...plan.entry.municipalityLabels].join(', ')}`)
}
}
if (prioritariaOverrides.length > 0) {
console.log('\n— PRIORITÁRIAS sobrepõe MAPA GERAL (aplica-se a PRIORITÁRIAS) —')
for (const override of prioritariaOverrides) {
console.log(
` ${override.label} · ${override.column}: ${JSON.stringify(override.prior)} (era ${JSON.stringify(override.geral)})`,
)
}
}
if (skippedSegments.length > 0) {
console.log('\n— Segmentos descartados (notas/coletivos/incertezas — permanecem só na planilha) —')
for (const skipped of skippedSegments.slice(0, 80)) {
console.log(` [${skipped.label}] ${skipped.column}: ${JSON.stringify(skipped.segment)}`)
}
if (skippedSegments.length > 80) console.log(` … +${skippedSegments.length - 80} mais`)
}
if (skippedSalvador.length > 0) {
console.log('\n— Salvador (pulado; estimativas por ZE via UI) —')
console.log('\n— Salvador (pulado; estimativas e estratégia por ZE via UI) —')
for (const row of skippedSalvador) {
console.log(
` ${row.label}: ${row.expectationRaw ?? '(sem expectativa)'} · prioridade=${row.priorityRaw ?? '(vazia)'}`,
` ${row.label}: ${row.expectationRaw ?? '(sem expectativa)'} · prioridade=${row.priorityRaw ?? '(vazia)'}${row.hasStrategy ? ' · colunas de estratégia ignoradas' : ''}`,
)
}
}
@ -476,7 +1050,7 @@ if (unmatched.length > 0) {
}
if (parseErrors.length > 0) {
console.log('\n— EXPECTATIVA não parseada (linha ignorada) —')
console.log('\n— EXPECTATIVA não parseada (estimativa ignorada; estratégia segue) —')
for (const row of parseErrors.slice(0, 30)) {
console.log(` ${row.label} (${row.slug}): ${JSON.stringify(row.raw)}`)
}
@ -500,21 +1074,10 @@ if (warnings.length > 0) {
if (warnings.length > 60) console.log(` … +${warnings.length - 60} mais`)
}
const changing = matched.filter((row) => {
const currentEstimates = toVoteEstimateScenarioViewModel(row.doc.expectedVotes)
const nextEstimates = toVoteEstimateScenarioViewModel(row.expectedVotes)
const currentPriority = row.doc.priority === 'alta' ? 'alta' : 'normal'
return !voteEstimatesEqual(currentEstimates, nextEstimates) || currentPriority !== row.priority
})
console.log(`\nEscritas planejadas: ${matched.length} (delta vs DB: ${changing.length})`)
console.log(`\nMunicípios com escrita planejada: ${municipalityPlans.length} de ${matched.length}`)
console.log('Amostra (até 8):')
for (const row of matched.slice(0, 8)) {
console.log(
` ${row.doc.slug}: ${formatEstimates(toVoteEstimateScenarioViewModel(row.expectedVotes))} · priority=${row.priority} (DB era ${formatEstimates(
toVoteEstimateScenarioViewModel(row.doc.expectedVotes),
)}/${row.doc.priority ?? 'normal'})`,
)
for (const plan of municipalityPlans.slice(0, 8)) {
console.log(` ${plan.row.doc.slug}: ${plan.fields.join(', ')}`)
}
if (options.dryRun) {
@ -523,23 +1086,108 @@ if (options.dryRun) {
}
try {
await withPayloadTransaction(payload, async ({ req }) => {
for (const row of matched) {
await payload.update({
collection: 'municipality',
id: row.doc.id,
const totals = await withPayloadTransaction(payload, async ({ req }) => {
let usersCreated = 0
for (const plan of staffPlans.values()) {
if (plan.userId != null) continue
const created = await payload.create({
collection: 'campaignUser',
data: {
expectedVotes: row.expectedVotes,
priority: row.priority,
name: plan.name,
role: plan.role,
// Payload requires an email or username on auth creates. The reserved
// ".invalid" TLD guarantees the address never routes; the random password
// is never shared — an admin swaps in real credentials later.
email: `${plan.slug}@planilha.invalid`,
password: randomBytes(24).toString('base64url'),
},
depth: 0,
overrideAccess: true,
req,
})
plan.userId = created.id
usersCreated += 1
}
let deputiesCreated = 0
for (const plan of deputyPlans.values()) {
if (plan.deputyId != null) continue
const created = await payload.create({
collection: 'stateDeputy',
data: { name: plan.name },
depth: 0,
overrideAccess: true,
req,
})
plan.deputyId = created.id
deputiesCreated += 1
}
let leadershipsCreated = 0
let leadershipsLinked = 0
for (const plan of leadershipPlans.values()) {
if (plan.existing) {
if (plan.missingIds.length === 0) continue
await payload.update({
collection: 'leadership',
id: plan.existing.id,
data: {
municipalities: [...plan.existing.municipalityIds, ...plan.missingIds],
},
depth: 0,
overrideAccess: true,
req,
})
leadershipsLinked += 1
continue
}
const contact = await payload.create({
collection: 'contact',
data: {
name: plan.name,
state: 'BA',
city: plan.city,
},
depth: 0,
overrideAccess: true,
req,
})
await payload.create({
collection: 'leadership',
data: {
contact: contact.id,
municipalities: plan.municipalityIds,
},
depth: 0,
overrideAccess: true,
req,
})
leadershipsCreated += 1
}
let municipalitiesUpdated = 0
for (const row of matched) {
const data = buildMunicipalityData(row)
if (!data) continue
await payload.update({
collection: 'municipality',
id: row.doc.id,
data,
depth: 0,
overrideAccess: true,
req,
})
municipalitiesUpdated += 1
}
return { usersCreated, deputiesCreated, leadershipsCreated, leadershipsLinked, municipalitiesUpdated }
})
console.log(`\n[seed:projecao] OK — ${matched.length} município(s) atualizados (overwrite).\n`)
console.log(
`\n[seed:projecao] OK — ${totals.municipalitiesUpdated} município(s) atualizados; ` +
`${totals.usersCreated} usuário(s), ${totals.deputiesCreated} dobradinha(s), ` +
`${totals.leadershipsCreated} liderança(s) criados; ${totals.leadershipsLinked} liderança(s) existentes vinculadas.\n`,
)
process.exit(0)
} catch (error) {
console.error(`\n[seed:projecao] ${error instanceof Error ? error.message : String(error)}\n`)

View file

@ -41,7 +41,7 @@ export const searchActionPlanContactOptions = async (
overrideAccess: false,
})
return result.docs.map(({ id, name, phone }) => ({ id, name, phone }))
return result.docs.map(({ id, name, phone }) => ({ id, name, phone: phone ?? null }))
}
export const searchActionPlanLeadershipOptionsAction = async (

View file

@ -134,7 +134,7 @@ const validateActionPlanAdvisors: CollectionBeforeValidateHook = async ({ data,
})
if (eligibleAdvisors.docs.length !== advisorIDs.length) {
throw new APIError('Cada responsável deve ter papel de Coordenador Geral ou Assessor.', 400)
throw new APIError('Cada responsável deve ter papel de Coordenador Geral, Assessor ou Candidato.', 400)
}
return data

View file

@ -32,6 +32,8 @@ const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({
}
const phone = String(data.phone ?? originalDoc?.phone ?? '')
// Phone-less contacts (e.g. name-only leadership imports) have no uniqueness to enforce.
if (!phone) return data
const oldPhone =
operation === 'update' && typeof originalDoc?.phone === 'string'
? originalDoc.phone
@ -72,6 +74,11 @@ export const Contact: CollectionConfig = {
({ data }) => {
if (!data || data.phone === undefined) return data
if (data.phone === null || data.phone === '') {
data.phone = null
return data
}
const input = String(data.phone)
if (/^\d{11}$/.test(input)) {
if (!/^[1-9]{2}9\d{8}$/.test(input)) {
@ -112,7 +119,9 @@ export const Contact: CollectionConfig = {
label: 'Celular',
minLength: 11,
maxLength: 11,
required: true,
// Optional at the collection level so name-only records (e.g. leaderships imported
// from the projection sheet) can exist; UI flows still require it via zod schemas.
required: false,
index: true,
},
{

View file

@ -97,7 +97,7 @@ const validateMunicipalityAdvisors: CollectionBeforeValidateHook = async ({ data
})
if (eligibleAdvisors.docs.length !== advisorIDs.length) {
throw new APIError('Cada assessor deve ter papel de Coordenador Geral ou Assessor.', 400)
throw new APIError('Cada assessor deve ter papel de Coordenador Geral, Assessor ou Candidato.', 400)
}
return data

View file

@ -8,7 +8,7 @@ import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
export type ContactComboboxOption = {
id: number
name: string
phone: string
phone: string | null
}
type ContactComboboxProps = {
@ -21,7 +21,7 @@ type ContactComboboxProps = {
}
const contactLabel = (contact: ContactComboboxOption): string =>
`${contact.name} · ${contact.phone}`
contact.phone ? `${contact.name} · ${contact.phone}` : contact.name
export const ContactCombobox = ({
name,

View file

@ -63,3 +63,110 @@ export const mapSheetPriority = (raw: string | null | undefined): ProjectionPrio
if (raw == null) return 'normal'
return raw.trim().toLowerCase() === 'alta' ? 'alta' : 'normal'
}
/** Municipality politicalTrend.status values derivable from the sheet SITUAÇÃO column. */
export type ProjectionTrendStatus = 'favoravel' | 'neutra' | 'desfavoravel'
const stripDiacritics = (value: string): string =>
value.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
/**
* SITUAÇÃO cell → politicalTrend.status.
* MAPA GERAL uses long emoji labels ("🔴 QUEDA DE VOTOS — Requer ação imediata");
* PRIORITÁRIAS uses bare labels ("QUEDA" | "MANTÉM" | "AUMENTO").
* "NÃO DEFINIDA", footers, and anything else → null (leave the municipality untouched).
*/
export const parseSituationCell = (
raw: string | null | undefined,
): ProjectionTrendStatus | null => {
if (raw == null) return null
const normalized = stripDiacritics(raw).toUpperCase()
if (/\bQUEDA\b/.test(normalized)) return 'desfavoravel'
if (/\bMANTEM\b/.test(normalized)) return 'neutra'
if (/\bAUMENTO\b/.test(normalized)) return 'favoravel'
return null
}
export type NameCellSplit = {
/** Segments that look like an individual's name (may carry a descriptor, e.g. "Amilton do PT"). */
names: string[]
/** Segments dropped as notes/collectives/uncertainty — surfaced in the dry-run report. */
skipped: string[]
}
// Honorific abbreviations whose trailing period must not act as a sentence separator.
const HONORIFIC_ABBREVIATION = /\b(dr|dra|sr|sra|prof|profa|sec)\./gi
const HONORIFIC_PLACEHOLDER = '\u0000'
/**
* Note/collective segment detectors, evaluated on the accent-stripped lowercase segment.
* A match means "this is not an individual's name" — the segment goes to `skipped`.
*/
const NOTE_SEGMENT_PATTERNS: RegExp[] = [
/^-+$/, // placeholder dash
/\?/, // uncertainty markers ("Alex?", "vice?", "?")
// Leading action verbs ("VER COM VILMA", "CONFIRMAR COM JÚLIO", "Construindo Julio", "com Goiano")
/^(ver|falar|confirmar|aguardar|articulando|construindo|resgatar|tratar|definir|cobrar|com)\b/,
/\b(ver|falar) com\b/, // embedded notes ("Amauri - VER COM VÉU")
/-\s*(ver|cobrar|definir|confirmar|aguardar|tratar)\b/, // "Diego Pita - COBRAR IONÁ"
/\bvai\b/, // "SOLLA VAI INFORMAR", "Solla vai ver"
/^a definir\b/, // "A definir"
/^candidato d/, // "candidato do Prefeito"
// Collectives / relationships / roles without an individual's name
/^(pessoal|pesoal|grupo|novo nucleo|associacao|sindicato|sindasesb|csol|presidente)\b/,
/^(pessoa|irma|sogro|moca|assessora?|vereadora?) de\b/,
/^estadual do\b/,
/^gerente d/,
/^indigenas?$/,
/^ex[- ]?(vice|prefeit)/,
/^([oa] )?(vereadora?|vereadores|prefeit[oa]|vice)$/,
/^sec\.? saude$/,
]
const isNoteSegment = (segment: string): boolean => {
const normalized = stripDiacritics(segment).toLowerCase()
return NOTE_SEGMENT_PATTERNS.some((pattern) => pattern.test(normalized))
}
/**
* Splits a DOBRADINHAS / LIDERANÇAS / ASSESSOR RESPONSÁVEL cell into individual names.
* Separators: comma, slash, semicolon, newline, " e ", and sentence periods (honorifics
* like "Dr. Paulo" are protected). Parentheticals are extracted into `skipped` before
* splitting; note-like segments (action phrases, collectives, "?"-marked) are skipped.
*/
export const splitNameCell = (raw: string | null | undefined): NameCellSplit => {
const names: string[] = []
const skipped: string[] = []
const cell = raw?.trim()
if (!cell) return { names, skipped }
const withoutParentheticals = cell.replace(/\([^)]*\)/g, (match) => {
skipped.push(match.trim())
return ' '
})
const protectedCell = withoutParentheticals.replace(
HONORIFIC_ABBREVIATION,
`$1${HONORIFIC_PLACEHOLDER}`,
)
const segments = protectedCell.split(/(?:[,/;|\n\r]|\s+e\s+|\.\s+|\.$)+/i)
for (const segment of segments) {
const cleaned = segment.replaceAll(HONORIFIC_PLACEHOLDER, '.').replace(/\s+/g, ' ').trim()
if (!cleaned) continue
if (isNoteSegment(cleaned)) {
skipped.push(cleaned)
continue
}
// Stray edge punctuation from sheet typos ("Júlio-"); internal descriptors survive.
const name = cleaned.replace(/^[-–—\s]+/, '').replace(/[-–—\s]+$/, '')
if (!name) {
skipped.push(cleaned)
continue
}
names.push(name)
}
return { names, skipped }
}

View file

@ -0,0 +1,20 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Makes contact.phone nullable so name-only records can exist (e.g. leaderships
* imported from the projection sheet, E4R fase 2). UI flows keep requiring the
* phone via their zod schemas; only the collection/database constraint relaxes.
* Idempotent: DROP NOT NULL is a no-op when the column is already nullable.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "contact" ALTER COLUMN "phone" DROP NOT NULL;
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
// Restoring NOT NULL fails if phone-less rows exist — remove them first by hand.
await db.execute(sql`
ALTER TABLE "contact" ALTER COLUMN "phone" SET NOT NULL;
`)
}

View file

@ -24,6 +24,7 @@ import * as migration_20260723_200000_remodel_municipalities from './20260723_20
import * as migration_20260723_201000_add_state_deputy from './20260723_201000_add_state_deputy'
import * as migration_20260723_202000_reconcile_municipality_remodel from './20260723_202000_reconcile_municipality_remodel'
import * as migration_20260724_133600_drop_municipality_vote_goals from './20260724_133600_drop_municipality_vote_goals'
import * as migration_20260724_175500_contact_phone_optional from './20260724_175500_contact_phone_optional'
export const migrations = [
{
@ -156,4 +157,9 @@ export const migrations = [
down: migration_20260724_133600_drop_municipality_vote_goals.down,
name: '20260724_133600_drop_municipality_vote_goals',
},
{
up: migration_20260724_175500_contact_phone_optional.up,
down: migration_20260724_175500_contact_phone_optional.down,
name: '20260724_175500_contact_phone_optional',
},
]

View file

@ -353,7 +353,7 @@ export interface Contact {
id: number;
name: string;
email?: string | null;
phone: string;
phone?: string | null;
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
state:
| 'AC'

View file

@ -57,9 +57,17 @@ export const isCampaignStaff = (user: CampaignActor): boolean =>
isCampaignUser(user) &&
(user.role === 'coordinator' || user.role === 'advisor' || user.role === 'candidate')
/** Eligible relationship targets for advisor assignments (municipality / action plan). */
/**
* Eligible relationship targets for advisor assignments (municipality / action plan).
* The candidate is included: the projection sheet lists him as the responsible contact
* for some municipalities (decision 2026-07-24).
*/
export const eligibleCampaignStaffWhere: Where = {
or: [{ role: { equals: 'coordinator' } }, { role: { equals: 'advisor' } }],
or: [
{ role: { equals: 'coordinator' } },
{ role: { equals: 'advisor' } },
{ role: { equals: 'candidate' } },
],
}
export const getFreshCampaignUser = async (

View file

@ -104,7 +104,7 @@ export const actionPlanFormSelect = {
export type ActionPlanFormTaskViewModel = {
id: string | null
title: string
responsible: { id: number; name: string; phone: string } | null
responsible: { id: number; name: string; phone: string | null } | null
due: string | null
done: boolean
}
@ -124,7 +124,7 @@ export type ActionPlanFormViewModel = {
locality: string | null
organizationIDs: number[]
advisorIDs: number[]
responsible: { id: number; name: string; phone: string } | null
responsible: { id: number; name: string; phone: string | null } | null
leadership: { id: number; label: string } | null
tasks: ActionPlanFormTaskViewModel[]
}
@ -150,7 +150,11 @@ export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewM
organizationIDs: relationshipIds(plan.organizations),
advisorIDs: relationshipIds(plan.advisors),
responsible: isPopulatedRelationship<Contact>(plan.responsible)
? { id: plan.responsible.id, name: plan.responsible.name, phone: plan.responsible.phone }
? {
id: plan.responsible.id,
name: plan.responsible.name,
phone: plan.responsible.phone ?? null,
}
: null,
leadership: (() => {
const leadershipId = relationshipId(plan.leadership)
@ -168,7 +172,11 @@ export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewM
id: task.id ?? null,
title: task.title,
responsible: isPopulatedRelationship<Contact>(task.responsible)
? { id: task.responsible.id, name: task.responsible.name, phone: task.responsible.phone }
? {
id: task.responsible.id,
name: task.responsible.name,
phone: task.responsible.phone ?? null,
}
: null,
due: task.due ?? null,
done: Boolean(task.done),

View file

@ -82,6 +82,9 @@ export const createCampaignInviteForActor = async (
overrideAccess: true,
req,
})
if (!contact.phone) {
throw new Error('Cadastre o celular da liderança antes de gerar o convite.')
}
const inviteUrl = `${inviteBaseURL}/campanha/convite/${generated.token}`
return {
inviteUrl,

View file

@ -87,7 +87,7 @@ export const getCampaignInvitePageData = async (
kind: invite.kind,
profile: {
name: contact.name,
phone: contact.phone,
phone: contact.phone ?? '',
email: contact.email ?? null,
gender: contact.gender ?? null,
sector: leadership.sector ?? null,

View file

@ -132,7 +132,9 @@ export const redeemCampaignInviteAutofillRecord = async (
await acquireCampaignInviteAccountLocks(
payload,
req,
contactPhoneLockKeys([originalContact.phone, data.phone]),
contactPhoneLockKeys(
[originalContact.phone, data.phone].filter((phone): phone is string => Boolean(phone)),
),
)
await assertContactPhoneAvailable(
payload,
@ -208,7 +210,9 @@ export const redeemCampaignInviteLoginRecord = async (
})
let account = await findReusableLeadershipAccount(payload, req, leadership)
await acquireCampaignInviteAccountLocks(payload, req, [
...contactPhoneLockKeys([originalContact.phone, data.phone]),
...contactPhoneLockKeys(
[originalContact.phone, data.phone].filter((phone): phone is string => Boolean(phone)),
),
...(account?.username ? [`account-username:${account.username}`] : []),
`account-username:${data.phone}`,
...(account ? [`invite-redemption-user:${account.id}`] : []),

View file

@ -569,7 +569,7 @@ export class CampaignFixtures {
return consent
}
async createContact(input: ContactInput = {}): Promise<Contact> {
async createContact(input: ContactInput = {}): Promise<Contact & { phone: string }> {
const contact = await this.rootPayload.create({
collection: 'contact',
data: {
@ -582,7 +582,12 @@ export class CampaignFixtures {
depth: 0,
})
this.own('contact', contact)
return contact
// Contact.phone is optional at the collection level (name-only imports), but
// every fixture contact carries one — narrow so specs can rely on it.
if (typeof contact.phone !== 'string') {
throw new Error('Fixture contacts must always carry a phone.')
}
return { ...contact, phone: contact.phone }
}
/** Assign advisors to a seeded municipality (tracked for reset on cleanup). */

View file

@ -1127,7 +1127,7 @@ describe('campaign invite domain', () => {
await expect(
actions.redeemCampaignInviteAutofill({
...submissions[rejectedIndex]!,
phone: contacts[rejectedIndex]!.phone,
phone: contacts[rejectedIndex]!.phone!,
}),
).resolves.toEqual({ ok: true })
},

View file

@ -359,7 +359,7 @@ describe('campaign invite UI contracts', () => {
const fixture = await createPublicInviteScenario('login')
const formData = new FormData()
formData.set('name', fixture.contact.name)
formData.set('phone', fixture.contact.phone)
formData.set('phone', fixture.contact.phone!)
formData.set('email', fixture.contact.email ?? '')
formData.set('password', 'senha-segura')
formData.set('passwordConfirmation', 'senha-diferente')
@ -382,7 +382,7 @@ describe('campaign invite UI contracts', () => {
const fixture = await createPublicInviteScenario('login')
const formData = new FormData()
formData.set('name', fixture.contact.name)
formData.set('phone', fixture.contact.phone)
formData.set('phone', fixture.contact.phone!)
formData.set('email', fixture.contact.email ?? '')
formData.set('password', ' senha-segura ')
formData.set('passwordConfirmation', 'senha-segura')
@ -406,7 +406,7 @@ describe('campaign invite UI contracts', () => {
const fixture = await createPublicInviteScenario('autopreenchimento')
const formData = new FormData()
formData.set('name', 'Maria Atualizada')
formData.set('phone', fixture.contact.phone)
formData.set('phone', fixture.contact.phone!)
formData.set('email', fixture.contact.email ?? '')
formData.set('gender', 'feminino')
formData.set('sector', 'comunitario')

View file

@ -251,13 +251,24 @@ describe('municipality catalog seed and per-role access', () => {
}),
).rejects.toThrow()
// The candidate is eligible: the projection sheet lists him as responsible
// for some municipalities (decision 2026-07-24).
const candidate = await fixtures.createCampaignUser('candidate')
const assigned = await assignMunicipalityAdvisorsRecord(payload, coordinator, {
municipality: municipality.id,
advisors: [advisor.id],
advisors: [advisor.id, candidate.id],
})
fixtures.touchMunicipality(municipality.id)
expect(
assigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
).toEqual([advisor.id, candidate.id])
const advisorOnly = await assignMunicipalityAdvisorsRecord(payload, coordinator, {
municipality: municipality.id,
advisors: [advisor.id],
})
expect(
advisorOnly.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
).toEqual([advisor.id])
const scope = await getAccessibleMunicipalityIds(

View file

@ -4,6 +4,8 @@ import {
mapSheetPriority,
parseExpectationCell,
parseSheetNumber,
parseSituationCell,
splitNameCell,
} from '@/lib/projectionSheetParse'
describe('parseSheetNumber', () => {
@ -102,3 +104,167 @@ describe('mapSheetPriority', () => {
expect(mapSheetPriority('média')).toBe('normal')
})
})
describe('parseSituationCell', () => {
it('maps the long MAPA GERAL labels with emoji', () => {
expect(parseSituationCell('🔴 QUEDA DE VOTOS — Requer ação imediata')).toBe('desfavoravel')
expect(parseSituationCell('🟡 MANTÉM VOTAÇÃO — Acompanhar de perto')).toBe('neutra')
expect(parseSituationCell('🟢 AUMENTO DE VOTOS — Consolidar e ampliar')).toBe('favoravel')
})
it('maps the bare PRIORITÁRIAS labels', () => {
expect(parseSituationCell('QUEDA')).toBe('desfavoravel')
expect(parseSituationCell('MANTÉM')).toBe('neutra')
expect(parseSituationCell('AUMENTO')).toBe('favoravel')
})
it('returns null for undefined situations and footers', () => {
expect(parseSituationCell('NÃO DEFINIDA')).toBeNull()
expect(parseSituationCell('TOTAL GERAL')).toBeNull()
expect(parseSituationCell('')).toBeNull()
expect(parseSituationCell(null)).toBeNull()
})
})
describe('splitNameCell', () => {
it('splits on commas, slashes, and " e "', () => {
expect(splitNameCell('Mário, Romilson, Diquinha e Jamile')).toEqual({
names: ['Mário', 'Romilson', 'Diquinha', 'Jamile'],
skipped: [],
})
expect(splitNameCell('Júlio-').names).toEqual(['Júlio'])
expect(splitNameCell('Roberto/Oseas e Ademário')).toEqual({
names: ['Roberto', 'Oseas', 'Ademário'],
skipped: [],
})
expect(splitNameCell('Radiovaldo e Júlio e Ludimila')).toEqual({
names: ['Radiovaldo', 'Júlio', 'Ludimila'],
skipped: [],
})
})
it('keeps honorifics and descriptors as part of the name', () => {
expect(splitNameCell('Pedro Melo, Vera, Dr. Paulo e Yago').names).toEqual([
'Pedro Melo',
'Vera',
'Dr. Paulo',
'Yago',
])
expect(splitNameCell('Emiran, Amilton do PT e Moça de KK')).toEqual({
names: ['Emiran', 'Amilton do PT'],
skipped: ['Moça de KK'],
})
expect(splitNameCell('Markão, Sandrinho, Prefeito e Eleni Sec Saúde')).toEqual({
names: ['Markão', 'Sandrinho', 'Eleni Sec Saúde'],
skipped: ['Prefeito'],
})
})
it('extracts parentheticals before splitting', () => {
expect(splitNameCell('Cintia/Fernando/ (shaulin e davi terra ) e Silvio')).toEqual({
names: ['Cintia', 'Fernando', 'Silvio'],
skipped: ['(shaulin e davi terra )'],
})
expect(splitNameCell('Lucy, Edizio e Marianna (com Jamile)')).toEqual({
names: ['Lucy', 'Edizio', 'Marianna'],
skipped: ['(com Jamile)'],
})
})
it('drops uncertainty markers and placeholder dashes', () => {
expect(splitNameCell('Alex? e Júlio')).toEqual({ names: ['Júlio'], skipped: ['Alex?'] })
expect(splitNameCell('Panfile, Eliomar, vice?')).toEqual({
names: ['Panfile', 'Eliomar'],
skipped: ['vice?'],
})
expect(splitNameCell('-')).toEqual({ names: [], skipped: ['-'] })
expect(splitNameCell('?')).toEqual({ names: [], skipped: ['?'] })
expect(splitNameCell(null)).toEqual({ names: [], skipped: [] })
})
it('drops action phrases and collectives', () => {
expect(splitNameCell('VER COM VILMA').names).toEqual([])
expect(splitNameCell('CONFIRMAR COM JÚLIO').names).toEqual([])
expect(splitNameCell('AGUARDAR DEFINIÇÃO DE CIBELE').names).toEqual([])
expect(splitNameCell('SOLLA VAI INFORMAR').names).toEqual([])
expect(splitNameCell('Solla vai ver').names).toEqual([])
expect(splitNameCell('Construindo pra trocar Galo por Julio ou Rosemberg').names).toEqual([])
expect(splitNameCell('Júlio e Estadual do Prefeito')).toEqual({
names: ['Júlio'],
skipped: ['Estadual do Prefeito'],
})
expect(splitNameCell('A definir').names).toEqual([])
expect(splitNameCell('candidato do Prefeito').names).toEqual([])
expect(splitNameCell('Chico e vereador')).toEqual({
names: ['Chico'],
skipped: ['vereador'],
})
expect(splitNameCell('TRATAR COM ADEMAR DELGADO').names).toEqual([])
expect(splitNameCell('Burica | DEFINIR ALYSON')).toEqual({
names: ['Burica'],
skipped: ['DEFINIR ALYSON'],
})
expect(splitNameCell('Diego Pita - COBRAR IONÁ').names).toEqual([])
expect(splitNameCell('Ex-vice prefeito').names).toEqual([])
expect(splitNameCell('o Prefeito').names).toEqual([])
expect(splitNameCell('Assessor de Fatima Nunes').names).toEqual([])
expect(splitNameCell('Presidente do PT e Netinho presidente do PT')).toEqual({
names: ['Netinho presidente do PT'],
skipped: ['Presidente do PT'],
})
expect(splitNameCell('Meiriane ex prefeita').names).toEqual(['Meiriane ex prefeita'])
expect(splitNameCell('Rogério e grupo de ACS, Rodrigo ')).toEqual({
names: ['Rogério', 'Rodrigo'],
skipped: ['grupo de ACS'],
})
expect(splitNameCell('Dr. Ruy e pesoal do PT e Alfredo Boasorte')).toEqual({
names: ['Dr. Ruy', 'Alfredo Boasorte'],
skipped: ['pesoal do PT'],
})
expect(splitNameCell('Sec. Saúde, Rosival Presidente do PT, Associação de mulheres')).toEqual({
names: ['Rosival Presidente do PT'],
skipped: ['Sec. Saúde', 'Associação de mulheres'],
})
expect(splitNameCell('Adiel e irmã de Nagib, pessoal do Circo (Oseas) e Pessoal do Roque')).toEqual(
{
names: ['Adiel'],
skipped: ['(Oseas)', 'irmã de Nagib', 'pessoal do Circo', 'Pessoal do Roque'],
},
)
})
it('splits sentence periods while keeping honorific abbreviations intact', () => {
expect(splitNameCell('Lucy. Falar com Fabio e com Goiano')).toEqual({
names: ['Lucy'],
skipped: ['Falar com Fabio', 'com Goiano'],
})
expect(splitNameCell('Telma, Breno e Nem do Caipe, Luis campineira.').names).toEqual([
'Telma',
'Breno',
'Nem do Caipe',
'Luis campineira',
])
})
it('keeps compound entries with embedded descriptors', () => {
expect(
splitNameCell(
'David Terra, Geysa e Ana Fraga, Adalicio - Sindicato dos Trabalhadores Rurais e sogro de Matheus, Fernando de Cairu, Jaci Perninha',
),
).toEqual({
names: [
'David Terra',
'Geysa',
'Ana Fraga',
'Adalicio - Sindicato dos Trabalhadores Rurais',
'Fernando de Cairu',
'Jaci Perninha',
],
skipped: ['sogro de Matheus'],
})
expect(splitNameCell('Getúlio e vereadores')).toEqual({
names: ['Getúlio'],
skipped: ['vereadores'],
})
})
})