diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index a844fff8..0867081d 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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`), 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 diff --git a/AGENTS.md b/AGENTS.md index aaa9fd9f..27a26eb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`), 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 `@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**. diff --git a/docs/plans/import-planilha-projecao.md b/docs/plans/import-planilha-projecao.md index 9d687b85..e1300f06 100644 --- a/docs/plans/import-planilha-projecao.md +++ b/docs/plans/import-planilha-projecao.md @@ -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 `@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 diff --git a/scripts/import-projecao.mjs b/scripts/import-projecao.mjs index a811be7b..0af87f07 100644 --- a/scripts/import-projecao.mjs +++ b/scripts/import-projecao.mjs @@ -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 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} */ 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} */ -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} */ - 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} */ + 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, municipalityLabels: Set, municipalityIds: Set, cities: Set }} RosterEntry + */ + +/** @returns {RosterEntry} */ +const emptyRosterEntry = () => ({ + variants: new Map(), + municipalityLabels: new Set(), + municipalityIds: new Set(), + cities: new Set(), +}) + +/** + * @param {Map} 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 | 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} */ +/** @type {Map} */ 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} */ +const staffRoster = new Map() +/** @type {Map} */ +const deputyRoster = new Map() +/** @type {Map} */ +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`) diff --git a/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts b/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts index 172a9306..5ec6d03f 100644 --- a/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts +++ b/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts @@ -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 ( diff --git a/src/collections/ActionPlan.ts b/src/collections/ActionPlan.ts index e74e7c96..ba0bdfde 100644 --- a/src/collections/ActionPlan.ts +++ b/src/collections/ActionPlan.ts @@ -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 diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts index 3b3ef5f9..0271bb33 100644 --- a/src/collections/Contact.ts +++ b/src/collections/Contact.ts @@ -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, }, { diff --git a/src/collections/Municipality.ts b/src/collections/Municipality.ts index faefe621..e8318846 100644 --- a/src/collections/Municipality.ts +++ b/src/collections/Municipality.ts @@ -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 diff --git a/src/components/campaign/ContactCombobox.tsx b/src/components/campaign/ContactCombobox.tsx index e5219f4f..64e5983a 100644 --- a/src/components/campaign/ContactCombobox.tsx +++ b/src/components/campaign/ContactCombobox.tsx @@ -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, diff --git a/src/lib/projectionSheetParse.ts b/src/lib/projectionSheetParse.ts index bc11a43c..d16a7c90 100644 --- a/src/lib/projectionSheetParse.ts +++ b/src/lib/projectionSheetParse.ts @@ -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 } +} diff --git a/src/migrations/20260724_175500_contact_phone_optional.ts b/src/migrations/20260724_175500_contact_phone_optional.ts new file mode 100644 index 00000000..cb179f49 --- /dev/null +++ b/src/migrations/20260724_175500_contact_phone_optional.ts @@ -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 { + await db.execute(sql` + ALTER TABLE "contact" ALTER COLUMN "phone" DROP NOT NULL; + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + // 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; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 1043f870..38a0f4d6 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -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', + }, ] diff --git a/src/payload-types.ts b/src/payload-types.ts index 8b0c40eb..85f07f38 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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' diff --git a/src/utilities/access/shared.ts b/src/utilities/access/shared.ts index f5a28c82..65766215 100644 --- a/src/utilities/access/shared.ts +++ b/src/utilities/access/shared.ts @@ -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 ( diff --git a/src/utilities/actionPlanViewModels.ts b/src/utilities/actionPlanViewModels.ts index ae1851e6..5df5cb6a 100644 --- a/src/utilities/actionPlanViewModels.ts +++ b/src/utilities/actionPlanViewModels.ts @@ -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(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(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), diff --git a/src/utilities/campaignInviteCreation.ts b/src/utilities/campaignInviteCreation.ts index a5ab73c0..66e61848 100644 --- a/src/utilities/campaignInviteCreation.ts +++ b/src/utilities/campaignInviteCreation.ts @@ -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, diff --git a/src/utilities/campaignInvitePageData.ts b/src/utilities/campaignInvitePageData.ts index 8f96efbc..28fcb1d9 100644 --- a/src/utilities/campaignInvitePageData.ts +++ b/src/utilities/campaignInvitePageData.ts @@ -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, diff --git a/src/utilities/campaignInviteRedemption.ts b/src/utilities/campaignInviteRedemption.ts index fdae705f..f8655204 100644 --- a/src/utilities/campaignInviteRedemption.ts +++ b/src/utilities/campaignInviteRedemption.ts @@ -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}`] : []), diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index cc894d41..5edc6d98 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -569,7 +569,7 @@ export class CampaignFixtures { return consent } - async createContact(input: ContactInput = {}): Promise { + async createContact(input: ContactInput = {}): Promise { 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). */ diff --git a/tests/int/campaignInvite.int.spec.ts b/tests/int/campaignInvite.int.spec.ts index 3a75203b..669efc25 100644 --- a/tests/int/campaignInvite.int.spec.ts +++ b/tests/int/campaignInvite.int.spec.ts @@ -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 }) }, diff --git a/tests/int/campaignInviteUi.int.spec.ts b/tests/int/campaignInviteUi.int.spec.ts index b79a01df..77eaedf7 100644 --- a/tests/int/campaignInviteUi.int.spec.ts +++ b/tests/int/campaignInviteUi.int.spec.ts @@ -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') diff --git a/tests/int/campaignMunicipalityAccess.int.spec.ts b/tests/int/campaignMunicipalityAccess.int.spec.ts index 8ab38a04..0ac8bb43 100644 --- a/tests/int/campaignMunicipalityAccess.int.spec.ts +++ b/tests/int/campaignMunicipalityAccess.int.spec.ts @@ -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( diff --git a/tests/unit/projectionSheetParse.unit.spec.ts b/tests/unit/projectionSheetParse.unit.spec.ts index a1731349..a63cba58 100644 --- a/tests/unit/projectionSheetParse.unit.spec.ts +++ b/tests/unit/projectionSheetParse.unit.spec.ts @@ -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'], + }) + }) +})