diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index a88748ef..cb2435e4 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -43,6 +43,8 @@ alwaysApply: false - **A3 Baseline TSE 2022 Fase 1** — collections `electionTally`/`electionCandidateVote`/`electionCandidate` (grupo `Dados Eleitorais`, leitura para `campaignUser`/`users` via `canReadElectionData`, escrita admin-only), migration `20260718_195854_add_election_results`, seed local `pnpm db:seed:tse` (idempotente por escopo year/office/turn, delete+bulk insert via drizzle, guard de banco local). Sem PII (sem CPF/título); identidade cross-ano por `identityKey` hash. (UI do baseline = A4, item de status acima.) - **Em 2026-07-18, C2 Cadastro nominal de apoiadores — engenharia pronta e mesclada em `main` (pendente deploy + lote jurídico).** Collection `supporter` (join `Contact`↔campanha, núcleo opcional), migration `20260718_222656_add_supporter` com `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent por chaves `apoiador-cadastro` / `apoiador-intencao-voto` via `campaignConsent.ts` genérico, actions (create / intenção / import CSV só `geral` / remover dados), UI `/campanha/apoiadores` (lista+KPIs, ficha, wizard import, kit mínimo `wa.me`). Telefone obrigatório; `lideranca` sem acesso à área. Produção com dados reais espera deploy (build Vercel aplica a migration) + Consent keys + aprovação jurídica (Onda 0). - **Em 2026-07-18, C6 Escala e DRY pós-C2 registrado no roadmap** (após `/simplify` do C2): shells compartilhados com listas de núcleos, import bulk drizzle + skip phone-invariant, preview token, KPI agregado SQL, StrictCombobox/AlertDialog. Plano: [`docs/plans/escala-dry-pos-c2.md`](../../../docs/plans/escala-dry-pos-c2.md). Depende do merge de C2; fases 3–5 cortáveis se a base permanecer pequena. +- **Em 2026-07-19, C9 Escala e DRY pós-C8 entregue:** `supporterListFilters.ts` (filtro unificado Payload↔SQL + `contactSearchQuery`), `loadSupportersPageData` (1× lookup de núcleos do coordenador), `apoiadores/[id]/formActions` no mapper compartilhado, `fieldError` nos 8 componentes de form restantes. Plano: [`docs/plans/escala-dry-pos-c8.md`](../../../docs/plans/escala-dry-pos-c8.md). +- **Em 2026-07-19, C10 Escala e DRY pós-C9 registrado** (após `/simplify` do C9): dedup de access na lista (`req.context`), query única de núcleos do coordenador, `errorProps` nos forms grandes. Plano: [`docs/plans/escala-dry-pos-c9.md`](../../../docs/plans/escala-dry-pos-c9.md). ## Decisões diff --git a/docs/plans/escala-dry-pos-c8.md b/docs/plans/escala-dry-pos-c8.md index c0eab80d..f1e7efdb 100644 --- a/docs/plans/escala-dry-pos-c8.md +++ b/docs/plans/escala-dry-pos-c8.md @@ -1,6 +1,6 @@ # Escala e DRY pós-C8 (apoiadores / filtros / forms) -Status: registrado no roadmap (fases pendentes) +Status: implementado (Fases 1–4, 2026-07-19) Atualizado em: 2026-07-19 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C9) Responsável: — @@ -38,46 +38,44 @@ Os revisores (quality / reuse / performance) marcaram como **importantes e maior ## Questões em aberto -- **Filtro unificado: AST neutro vs helper de termos de busca?** **Recomendação:** extrair pelo menos `buildSupporterSearchTerms(q)` compartilhado (telefone, dígitos, ILIKE) na Fase 1; AST completo Payload↔SQL só se a Fase 1 ainda duplicar branches de `voteIntention`/`nucleus`. -- **Memo de núcleos: `React.cache(getCoordinatorNucleusIds)` vs `loadSupportersPageData`?** **Recomendação:** loader único `loadSupportersPageData` que retorna `{ list, overviewInputs, nucleusOptions, coordinatorNucleusIds }` — mais explícito que cache implícito e testável em int. -- **Migrar componentes de form fora do diff do C8?** **Recomendação:** sim na Fase 4 — são o mesmo débito DRY da vertical; escopo mecânico (`fieldError` / `errorProps`), sem mudar copy. +- **Filtro unificado: AST neutro vs helper de termos de busca?** **Resolvido (2026-07-19):** módulo `supporterListFilters.ts` com `buildSupporterSearchTerms` + adaptadores `toPayloadWhere` / `toAggregateSqlConditions` — sem AST completo; access SQL permanece no aggregate. +- **Memo de núcleos: `React.cache(getCoordinatorNucleusIds)` vs `loadSupportersPageData`?** **Resolvido (2026-07-19):** loader único `loadSupportersPageData` retorna `{ result, state, redirectHref, nucleusOptions, overview, coordinatorNucleusIds }`; `loadAccessibleNucleusOptions` aceita `coordinatorNucleusIds` pré-resolvidos para coordenador. +- **Migrar componentes de form fora do diff do C8?** **Resolvido (2026-07-19):** Fase 4 migrou os 8 componentes listados para `fieldError` de `campaignFormFields.ts`. ## Abordagem proposta ```mermaid flowchart TD C8["C8 apoiadores ✓"] --> F1 - F1["Fase 1 — Filtro unificado
supporterUi + aggregate SQL"] - F1 --> F2["Fase 2 — contactSearchQuery + loader página
1× núcleos coordenador"] - F2 --> F3["Fase 3 — formActions detalhe apoiador
contrato message preservado"] + F1["Fase 1 — Filtro unificado
supporterListFilters"] + F1 --> F2["Fase 2 — contactSearchQuery + loader página
loadSupportersPageData"] + F2 --> F3["Fase 3 — formActions detalhe apoiador
message-only mapper"] F3 --> F4["Fase 4 — fieldError nos componentes restantes"] C7["C7 contactSearchQuery ✓"] -.-> F2 ``` -### Fase 1 — Núcleo de filtros de apoiadores +### Fase 1 — Núcleo de filtros de apoiadores ✓ -- Extrair condições compartilhadas de `buildSupporterListWhere` e `buildAggregateSql` para `src/utilities/supporterListFilters.ts` (ou equivalente): `q`, telefone, `voteIntention`, `city`, `nucleus`, mais adaptadores `toPayloadWhere(state)` / `toAggregateSqlConditions(state, access)`. -- Testes int: mesmo `SupporterListState` produz o mesmo conjunto filtrado na lista e no overview (fixture com `q`, cidade, núcleo, intenção). -- Critério: mudança futura de filtro exige editar um só lugar. +- `src/utilities/supporterListFilters.ts`: `buildSupporterSearchTerms`, `toPayloadWhere`, `toAggregateSqlConditions`. +- `buildSupporterListWhere` e `buildAggregateSql` delegam ao módulo compartilhado. +- Testes: `tests/int/supporterListFilters.int.spec.ts` + paridade lista/overview em `campaignSupporter.int.spec.ts`. -### Fase 2 — `contactSearchQuery` + loader da página +### Fase 2 — `contactSearchQuery` + loader da página ✓ -- Em `buildAggregateSql` (e `buildSupporterListWhere` se ainda aceitar `q` curto): ignorar `q` quando `!isContactSearchQueryReady(q)` — alinhado a `ContactCombobox` / C7. -- Criar `loadSupportersPageData` em `supporterPageData.ts`: uma chamada a `getCoordinatorNucleusIds` para `coordenador`, repassar IDs para list constraints (se viável) + overview + evitar prefetch duplicado na `page.tsx`. -- Avaliar `React.cache` só como otimização secundária se o loader único não bastar. +- Filtros ignoram `q` quando `!isContactSearchQueryReady(q)`. +- `loadSupportersPageData` em `supporterPageData.ts`; `apoiadores/page.tsx` usa loader único. +- Int: 1× `getCoordinatorNucleusIds` por render de coordenador. -### Fase 3 — `apoiadores/[id]/formActions.ts` +### Fase 3 — `apoiadores/[id]/formActions.ts` ✓ -- Migrar `setSupporterVoteIntentionFormAction` / `removeSupporterDataFormAction` para `mapCampaignFormActionError` com `resolveBoundaryMessage` ou branches que mantêm `message` (não `fieldErrors`) onde `SupporterDetail` espera banner global. -- Espelhar padrão de `apoiadores/novo/formActions.ts` (`getSupporterFormError`) onde aplicável. +- `mapCampaignFormActionError` + `toMessageOnlyState` preservando contrato `message` da UI. -### Fase 4 — Componentes de form restantes +### Fase 4 — Componentes de form restantes ✓ -- Substituir `errorFor` / `state.fieldErrors?.x?.[0]` por `fieldError` ou `errorProps` de `campaignFormFields.ts` em: `NucleusForm`, `ActionPlanForm`, `NucleusTerritoryFields`, `NucleusTerritoryAndZonesFields`, `VoteEstimateDialog`, `ActionPlanUpdateForm`, `LeadershipPrimaryContactAction`, `NucleusIntelligenceDialog` (lista do simplify 2026-07-19). -- Opcional baixa prioridade: `planos/formActions` regex de unique → pre-check de título como em `nucleos/formActions` (`existingActionPlanState`). +- `fieldError` em: `NucleusForm`, `ActionPlanForm`, `NucleusTerritoryFields`, `NucleusTerritoryAndZonesFields`, `VoteEstimateDialog`, `ActionPlanUpdateForm`, `LeadershipPrimaryContactAction`, `NucleusIntelligenceDialog`. -**Migration:** nenhuma prevista. Sem Consent novo. +**Migration:** nenhuma. Sem Consent novo. ## Dependências @@ -99,10 +97,28 @@ flowchart TD - [escala-dry-pos-c6.md](escala-dry-pos-c6.md) — C8 entregue; precedente do formato - [escala-dry-pos-c3.md](escala-dry-pos-c3.md) — `contactSearchQuery` (C7) - [escala-dry-pos-c2.md](escala-dry-pos-c2.md) — cadastro nominal C2/C6 +- `src/utilities/supporterListFilters.ts` — núcleo de filtros unificado - `src/utilities/supporterUi.ts` — `buildSupporterListWhere` -- `src/utilities/supporterListOverviewAggregate.ts` — `buildAggregateSql`, `resolveAccessConstraint` -- `src/app/(campaign)/campanha/(app)/apoiadores/page.tsx` — prefetch / waterfall overview +- `src/utilities/supporterListOverviewAggregate.ts` — aggregate SQL + `resolveAccessConstraint` +- `src/app/(campaign)/campanha/(app)/apoiadores/page.tsx` — `loadSupportersPageData` - `src/lib/contactSearchQuery.ts` — `isContactSearchQueryReady` -- `src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts` — ladder legado +- `src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts` — mapper compartilhado - `src/utilities/campaignFormFields.ts` — `fieldError`, `errorProps` - AGENTS.md — `Contact`, `overrideAccess: false`, naming inglês + +## Revisão (2026-07-19) + +Implementação das Fases 1–4 conforme plano de implementação C9: `supporterListFilters.ts`, `loadSupportersPageData`, `contactSearchQuery` nos filtros, `formActions` do detalhe no mapper, `fieldError` nos 8 componentes restantes. Testes unit + int adicionados. Roadmap atualizado (C8 mesclado, C9 entregue). + +## Simplify (2026-07-19) + +Passagem `/simplify` sobre o diff do C9 aplicou limpezas pontuais: `normalizeContactSearchQuery` em `buildSupporterSearchTerms`, paralelismo no loader, spy de teste corrigido, KPI aggregate isolado por `searchTag`, `aria-describedby` com `hasError` no `LeadershipPrimaryContactAction`. Validado com testes int + scan Aikido. + +Os débitos que os revisores marcaram como importantes e maiores que cleanup foram registrados como item **C10** — [escala-dry-pos-c9.md](escala-dry-pos-c9.md): + +1. **Lista ainda re-dispara access** — `payload.find` com `overrideAccess: false` chama `getAccessibleNucleusIds` → segunda ida a `getCoordinatorNucleusIds` na mesma renderização. +2. **Dois round-trips de `electoralNucleus`** — `getCoordinatorNucleusIds` (só IDs) + `loadAccessibleNucleusOptions` (name/slug) no mesmo escopo. +3. **`errorProps` não migrado** — forms grandes ainda repetem wiring manual de `aria-*` apesar de `fieldError`. +4. **Prefetch não estende à página de criação** — `loadSupporterCreatePageData` pode repetir o padrão. + +**Explicitamente fora (tradeoffs aceitos no C9/simplify):** AST neutro único Payload↔SQL; waterfall lista→overview; unificar lista+aggregate; remover alias `errorFor`; hoist de `toMessageOnlyState`. diff --git a/docs/plans/escala-dry-pos-c9.md b/docs/plans/escala-dry-pos-c9.md new file mode 100644 index 00000000..42c04bc7 --- /dev/null +++ b/docs/plans/escala-dry-pos-c9.md @@ -0,0 +1,109 @@ +# Escala e DRY pós-C9 (apoiadores / access / forms) + +Status: registrado no roadmap (fases pendentes) +Atualizado em: 2026-07-19 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C10) +Responsável: — + +## Contexto + +O C9 ([escala-dry-pos-c8.md](escala-dry-pos-c8.md)) entregou filtro unificado Payload↔SQL (`supporterListFilters.ts`), `contactSearchQuery` no aggregate/lista, `loadSupportersPageData` com prefetch de `getCoordinatorNucleusIds`, `apoiadores/[id]/formActions` no mapper compartilhado e `fieldError` nos componentes de form restantes. Uma passagem `/simplify` (2026-07-19) aplicou limpezas pontuais: `normalizeContactSearchQuery` nos termos de busca, paralelismo no loader, spy de teste corrigido, KPI aggregate isolado por `searchTag`, `aria-describedby` com `hasError` no `LeadershipPrimaryContactAction`. + +Os revisores (quality / reuse / performance) marcaram como **importantes e maiores que cleanup** os follow-ups abaixo. Sem registro, o coordenador continua pagando lookups redundantes por navegação em `/campanha/apoiadores` e o DRY de acessibilidade nos forms grandes fica pela metade. + +1. **Lista de apoiadores ainda re-dispara access do coordenador.** `loadSupportersPageData` prefetcha `getCoordinatorNucleusIds` para overview e options, mas `loadSupporterListPageData` chama `payload.find` com `overrideAccess: false` → `canReadSupporter` → `getAccessibleNucleusIds` → **segunda** chamada a `getCoordinatorNucleusIds` na mesma renderização (sem `req` compartilhado / contexto pré-populado). +2. **Dois round-trips de `electoralNucleus` para o coordenador.** `getCoordinatorNucleusIds` seleciona só `{ id: true }`; em seguida `loadAccessibleNucleusOptions` faz outro `find` com `name`/`slug` no mesmo conjunto de IDs — poderia ser uma query com `select: { id, name, slug }`. +3. **`errorProps` não migrado nos forms grandes.** C9 adotou `fieldError` + alias local `errorFor`, mas `NucleusForm`, `ActionPlanForm`, `NucleusTerritoryFields`, `NucleusTerritoryAndZonesFields`, `VoteEstimateDialog`, `ActionPlanUpdateForm`, `LeadershipPrimaryContactAction` e `NucleusIntelligenceDialog` ainda repetem manualmente `aria-invalid` / `aria-describedby` em vez de `errorProps` de `campaignFormFields.ts` (precedente: `CampaignInviteForm` / `NucleusUpdateForm` no C8). +4. **Prefetch de escopo não estende à página de criação.** `loadSupporterCreatePageData` ainda pode repetir o mesmo padrão de access/options sem o escopo pré-resolvido do coordenador. + +**Explicitamente fora (revisores pediram skip no simplify / decisão C9):** remover o alias `errorFor` (reduz repetição em forms grandes); AST neutro único Payload↔SQL (adaptadores paralelos são o desenho do C9); waterfall lista→overview (precisa de `totalDocs` da lista); unificar lista+aggregate numa query SQL (só se latency reclamar); retirar `coordinatorNucleusIds` do retorno da página (útil para testes int); hoist de `toMessageOnlyState` para `campaignFormActionError.ts` (só quando houver segundo consumidor). + +## Objetivos + +- Navegação em `/campanha/apoiadores` como `coordenador` resolve escopo de núcleos **uma vez** por request — lista, overview e options compartilham o mesmo prefetch (via `req.context` ou constraint injetado), sem segunda ida a `getCoordinatorNucleusIds`. +- Opções de núcleo do coordenador saem de **uma** query `electoralNucleus` com `id`+`name`+`slug`. +- Forms grandes da campanha usam `errorProps` para wiring de `aria-*` consistente com `CampaignInviteForm`. +- Guardrails: sem novo `Consent`; sem migration; `overrideAccess: false` com `user` nas leituras de apoiador; não alterar semântica de filtros (`supporterListFilters`). + +## Decisões travadas + +- **Um item C10, três fases ordenadas.** Mesmo racional de A7/B5/C8/C9: um ID de roadmap, PRs por fase. Ordem: dedup de access na lista (ganho real por page view) → query única de núcleos (I/O) → `errorProps` nos forms (mecânico, UI). +- **Dependência dura de C9.** Só faz sentido com filtro unificado e `loadSupportersPageData` já no código; não reabre AST de filtros nem mapper do detalhe. +- **Fase 1 prefere `req.context` ao bypass de access.** `getAccessibleNucleusIds` já cacheia por `req` (`ACCESSIBLE_NUCLEUS_IDS_CONTEXT_KEY`); pré-popular o contexto no loader Local API é menos invasivo que `overrideAccess: true` na lista ou duplicar constraint de `canReadSupporter`. +- **Manter alias `errorFor`.** O `/simplify` concluiu que o wrapper local reduz ruído nos forms grandes; Fase 3 migra para `errorProps(fieldErrors, name, idPrefix)` **dentro** do `errorFor`, não remove o alias. +- **Cortável** se a base nominal permanecer pequena e poucos coordenadores usarem `/apoiadores` diariamente. Fase 3 é a mais cortável (só a11y/DRY); Fase 1 evita drift de perf sob carga. +- **i18n e naming** (AGENTS.md): identificadores em inglês (`loadCoordinatorNucleusScope`, `prefillAccessibleNucleusContext`), strings visíveis em pt-BR inalteradas. + +## Questões em aberto + +- **Fase 1: `getPayload` com `req` sintético vs `payload.find({ req })` do loader?** **Recomendação:** construir `req` mínimo reutilizável no `loadSupportersPageData` (com `user`, `payload`, `context` pré-preenchido) e passá-lo às três leituras (`list`, `overview` aggregate já bypassa access; `options` pode continuar `overrideAccess: true` com IDs pré-resolvidos). Teste int asserta contagem de `getCoordinatorNucleusIds` = 1 **e** de `getAccessibleNucleusIds` sem cache miss na lista. +- **Fase 2: generalizar `getCoordinatorNucleusIds` ou helper novo?** **Recomendação:** novo `loadCoordinatorNucleusScope(payload, coordinatorID, req?)` retornando `SupporterNucleusOption[]`; `getCoordinatorNucleusIds` permanece fino (`ids` derivados do scope) para não quebrar aggregate/access existentes. +- **Fase 3: prefixo de `id` por form?** **Recomendação:** um `idPrefix` estável por componente (`nucleus-form`, `action-plan-form`, …) espelhando o padrão do `CampaignInviteForm`; não unificar prefixos entre forms distintos na mesma página. + +## Abordagem proposta + +```mermaid +flowchart TD + C9["C9 Filtros + loader ✓"] --> F1 + F1["Fase 1 — req.context / access dedup na lista"] + F1 --> F2["Fase 2 — loadCoordinatorNucleusScope
(1× electoralNucleus)"] + F1 --> F3["Fase 3 — errorProps nos 8 forms"] + F2 -.options + ids.-> F4["Fase 4 opcional — create page prefetch"] +``` + +### Fase 1 — Dedup de access na lista de apoiadores + +- Em `src/utilities/supporterPageData.ts`: ao montar `loadSupportersPageData`, após resolver `coordinatorNucleusIds`, pré-popular `req.context` com a chave usada por `getAccessibleNucleusIds` **ou** extrair helper `prefillAccessibleNucleusContext(req, user, ids)`. +- Passar esse `req` a `loadSupporterListPageData` → `payload.find({ ..., req, overrideAccess: false })`. +- Garantir que `canReadSupporter` / `getAccessibleNucleusIds` leem o cache e não chamam `getCoordinatorNucleusIds` de novo. +- Teste int: expandir o spy existente em `campaignSupporter.int.spec.ts` para cobrir também a lista (`payload.find` path), não só o loader agregado. + +### Fase 2 — Query única de núcleos do coordenador + +- Novo helper em `src/utilities/supporterPageData.ts` ou `campaignAccess.ts`: `loadCoordinatorNucleusScope` com `where: { coordinators: { contains } }`, `status: ativo`, `select: { id, name, slug }`. +- `loadSupportersPageData` usa o scope para `nucleusOptions` e deriva `coordinatorNucleusIds` via `.map(n => n.id)`. +- `getCoordinatorNucleusIds` pode delegar ao scope (só IDs) para manter uma fonte de query. +- Remover o segundo `find` de `loadAccessibleNucleusOptions` quando `coordinatorNucleusIds` vier do scope completo. + +### Fase 3 — `errorProps` nos forms grandes + +- Migrar os 8 componentes listados no Contexto: substituir pares manuais `errorFor` + `aria-*` por desestruturação de `errorProps(fieldErrors, field, idPrefix)`. +- Manter `const errorFor = (name) => fieldError(...)` onde simplificar `error` no JSX; ou `const field = (name) => errorProps(fieldErrors, name, idPrefix)` se reduzir linhas. +- Sem mudança de copy nem de contrato `formActions`. + +### Fase 4 (opcional) — Prefetch na criação de apoiador + +- `loadSupporterCreatePageData` reutiliza `loadCoordinatorNucleusScope` / context prefill do coordenador. +- Só entra se a Fase 1–2 já estiverem estáveis; pode ser cortada. + +**Migration:** nenhuma. Sem collection, sem Consent, sem server action nova. + +## Dependências + +- **Dura:** C9 Escala e DRY pós-C8 — implementado (`supporterListFilters`, `loadSupportersPageData`, forms C9). +- **Suave:** C8 `getCoordinatorNucleusIds` + context cache em `getAccessibleNucleusIds` — já no código. +- Reuso: `src/utilities/campaignAccess.ts`, `src/utilities/supporterPageData.ts`, `src/utilities/campaignFormFields.ts` (`errorProps`), `src/app/(campaign)/campanha/(app)/apoiadores/page.tsx`. + +## Não escopo + +- AST neutro único para filtros Payload↔SQL — decisão fechada no C9; mudanças futuras continuam em `supporterListFilters.ts`. +- Unificar lista + overview KPI numa query — [escala-dry-pos-c8.md](escala-dry-pos-c8.md) / C8 explicitamente fora; reavaliar só com evidência de latency. +- Remover waterfall lista→overview — tradeoff intencional (`totalDocs` antes do aggregate). +- Migrar `formActions` restantes fora da vertical de apoiadores — coberto por C7/C8; não repetir aqui. +- Hoist de `toMessageOnlyState` — fill-in quando surgir segundo consumidor message-only. + +## Referências + +- `docs/roadmap.md` — Trilha C, item C10; sequência Janela 2 +- [escala-dry-pos-c8.md](escala-dry-pos-c8.md) — C9 entregue; simplify 2026-07-19 +- [escala-dry-pos-c6.md](escala-dry-pos-c6.md) — precedente C8→C9 +- `src/utilities/supporterPageData.ts` — `loadSupportersPageData`, `loadAccessibleNucleusOptions` +- `src/utilities/campaignAccess.ts` — `getCoordinatorNucleusIds`, `getAccessibleNucleusIds`, context cache +- `src/collections/Supporter.ts` — `read: canReadSupporter` +- `src/utilities/campaignFormFields.ts` — `fieldError`, `errorProps` +- `tests/int/campaignSupporter.int.spec.ts` — spy de lookup único +- AGENTS.md — `overrideAccess: false`, naming inglês, campaign auth + +## Simplify (2026-07-19) + +Passagem `/simplify` sobre o diff do C9 aplicou limpezas pontuais sem mudar comportamento (ver Contexto). Débitos maiores registrados neste plano como item **C10** do roadmap. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3e23b30f..e7e54960 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 parcial 2026-07-19 (FormData território, contadores lista, locks, contactSearchQuery) — débitos pós-simplify registrados no plano; C8 Escala e DRY pós-C6 implementado (Fases 1–4 + duas passagens `/simplify`; pendente merge em `main`); C9 registrado — escala/DRY pós-C8 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md)) +Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 parcial 2026-07-19 (FormData território, contadores lista, locks, contactSearchQuery) — débitos pós-simplify registrados no plano; C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md)) Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos. @@ -76,8 +76,9 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de - **C3 Eventos / agenda de mobilização** — collection `actionPlan` + vertical `/campanha/planos` (lista com tabs Próximos/Todos/Realizados/Rascunhos, detalhe com tabs Visão geral/Tarefas/Atualizações, forms novo/editar), blocos "Próximos eventos" no overview de núcleos e no dashboard; `startAt` opcional só em rascunho (obrigatório ao sair de `rascunho`); access por `coordinators`/`leadership` (escopo `lideranca` só toggle `tasks.done` + append `updates`); transações via `withPayloadTransaction`; migration `20260718_222832_add_action_plan`. Sem `Consent` (dado interno de staff). [Plano](plans/eventos-agenda-mobilizacao.md). - **C7 Escala e DRY pós-C3 — parcial (2026-07-19)** — primeira fatia: `campaignTerritoryFormData`, contadores `taskTotal`/`taskDoneCount` + migration `20260719_014906_action_plan_list_perf`, locks advisory + `select` mínimo em toggle/append, `contactSearchQuery` (mín. 2 chars), rename `CampaignTerritoryFields`. Débitos do `/simplify` 2026-07-19 (composição territorial, shell de contato, leituras por aba, short-circuit de hooks, `taskProgress` no detalhe) registrados no plano — Fases 1–2 e restante de 4–5 pendentes. [Plano](plans/escala-dry-pos-c3.md). - **C6 Escala e DRY pós-C2 — implementado e mesclado em `main` (2026-07-19)** — import bulk drizzle, token HMAC + `supporterImportBatch`, KPI aggregate, shells compartilhados (`campaignListUrl`, `CampaignListPagination`, `campaignFormFields`, `mapCampaignFormActionError`). Duas passagens `/simplify`; débitos maiores → C8. [Plano](plans/escala-dry-pos-c2.md). -- **C8 Escala e DRY pós-C6 — implementado (2026-07-19; pendente merge)** — locks advisory em 1 RT, bulk com `.returning()` + leituras drizzle na txn, overview sem `COUNT(*)` redundante, migration `pg_trgm` em `contact`, `drizzleBulk.ts`, `getCoordinatorNucleusIds`, migração da maioria dos `formActions` para `mapCampaignFormActionError`. Duas passagens `/simplify` (pré- e pós-rebase com C7); débitos maiores → C9. [Plano](plans/escala-dry-pos-c6.md). -- **C9 Escala e DRY pós-C8 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: filtro unificado Payload↔SQL, wire de `contactSearchQuery` no aggregate, loader único da página de apoiadores (1× núcleos do coordenador), `apoiadores/[id]/formActions` + componentes de form restantes. [Plano](plans/escala-dry-pos-c8.md). +- **C8 Escala e DRY pós-C6 — implementado e mesclado em `main` (2026-07-19)** — locks advisory em 1 RT, bulk com `.returning()` + leituras drizzle na txn, overview sem `COUNT(*)` redundante, migration `pg_trgm` em `contact`, `drizzleBulk.ts`, `getCoordinatorNucleusIds`, migração da maioria dos `formActions` para `mapCampaignFormActionError`. Duas passagens `/simplify` (pré- e pós-rebase com C7); débitos maiores → C9. [Plano](plans/escala-dry-pos-c6.md). +- **C9 Escala e DRY pós-C8 — implementado (2026-07-19)** — filtro unificado Payload↔SQL (`supporterListFilters`), `contactSearchQuery` no aggregate/lista, `loadSupportersPageData` (1× núcleos do coordenador), `apoiadores/[id]/formActions` no mapper compartilhado, `fieldError` nos componentes de form restantes. Passagem `/simplify`; débitos maiores → C10. [Plano](plans/escala-dry-pos-c8.md). +- **C10 Escala e DRY pós-C9 registrado (2026-07-19)** — dedup de access na lista de apoiadores (`req.context`), query única de núcleos do coordenador (`loadCoordinatorNucleusScope`), `errorProps` nos forms grandes, prefetch opcional na criação. [Plano](plans/escala-dry-pos-c9.md). ### Ciclo 3 — Trilha E: mapa de projeção por município (registrado 2026-07-19) @@ -108,6 +109,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig | C7 Escala e DRY pós-C3 | — (sem design novo; reusa os de C3 + padrões de núcleos/C6) | [escala-dry-pos-c3.md](plans/escala-dry-pos-c3.md) | | C8 Escala e DRY pós-C6 | — (sem design novo; reusa os de C2/C6) | [escala-dry-pos-c6.md](plans/escala-dry-pos-c6.md) | | C9 Escala e DRY pós-C8 | — (sem design novo; reusa os de C2/C6/C8) | [escala-dry-pos-c8.md](plans/escala-dry-pos-c8.md) | +| C10 Escala e DRY pós-C9 | — (sem design novo; reusa os de C2/C6/C8/C9) | [escala-dry-pos-c9.md](plans/escala-dry-pos-c9.md) | | B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) | | D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) | | D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) | @@ -150,8 +152,9 @@ flowchart TD C6["C6 Escala e DRY pós-C2
(import/listas/forms) ✓"] C3["C3 Eventos / agenda de mobilização ✓"] C7["C7 Escala e DRY pós-C3
(parcial: contadores/locks/FormData)"] - C8["C8 Escala e DRY pós-C6
(locks bulk / leituras / forms DRY)"] - C9["C9 Escala e DRY pós-C8
(filtros unificados / loader / forms)"] + C8["C8 Escala e DRY pós-C6
(locks bulk / leituras / forms DRY) ✓"] + C9["C9 Escala e DRY pós-C8
(filtros unificados / loader / forms) ✓"] + C10["C10 Escala e DRY pós-C9
(access dedup / scope / errorProps)"] C4["C4 Demandas"] C5["C5 Operação dia D / GOTV
(proposto, a validar)"] end @@ -197,6 +200,7 @@ flowchart TD C6 -.escala da base.-> C5 C6 --> C8 C8 --> C9 + C9 --> C10 D1 --> D2 JUR -.chave de push.-> D2 A4 --> E2 @@ -209,7 +213,7 @@ flowchart TD A7 -.mesmo loader do baseline.-> E2 ``` -Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C8 implementado (pendente merge). C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C7 (após C3; suave: C6 F2 ✓), C8 (merge de C6 ✓; cortável se a base nominal permanecer pequena), C9 (merge de C8; cortável se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import). +Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C7 (após C3; suave: C6 F2 ✓), C10 (após C9 ✓; cortável se a base nominal permanecer pequena) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import). ### Sequência de execução por janela do calendário @@ -233,32 +237,33 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fil | 9 | A4 Baseline no produto + insight Gap vs 2022 **(implementado e mesclado em `main` 2026-07-18 — Fases 2–4 + simplify)** | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7, C8 | | 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7, C8 | | 11 | C7 Escala e DRY pós-C3 _(parcial 2026-07-19: FormData território, contadores lista, locks, `contactSearchQuery`; pendente: composição territorial, shell de contato, leituras por aba, short-circuit de hooks)_ | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8, E1+E3, E2 | -| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) **(implementado 2026-07-19 — Fases 1–4 + simplify; pendente merge)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7, E1+E3, E2, C9 | -| 13 | C9 Escala e DRY pós-C8 (filtro unificado Payload↔SQL, `contactSearchQuery` no aggregate, loader único `/apoiadores`, forms restantes) _(cortável se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas)_ | [detalhes](plans/escala-dry-pos-c8.md) | C8 (merge) | A4, C3, C7, E1+E3, E2 | -| 14 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela | -| 15 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8, C9 | +| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7, E1+E3, E2, C9 | +| 13 | C9 Escala e DRY pós-C8 (filtro unificado Payload↔SQL, `contactSearchQuery` no aggregate, loader único `/apoiadores`, forms restantes) **(implementado 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c8.md) | C8 (merge) | A4, C3, C7, E1+E3, E2, C10 | +| 14 | C10 Escala e DRY pós-C9 (dedup access lista, scope único de núcleos do coordenador, `errorProps` nos forms) _(cortável se a base nominal permanecer pequena; Fase 3 DRY pode entrar isolada)_ | [detalhes](plans/escala-dry-pos-c9.md) | C9 (merge) | A4, C3, C7, E1+E3, E2 | +| 15 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela | +| 16 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8, C9, C10 | **Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.** | Ordem | Item | Plano | Depende de | Paralelizável com | | ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------- | -| 16 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 + E3 (suave: E2 — tendência visível na revisão) | A5, A7, B3, B5, C4, D2 | -| 17 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, B3, B5, C4, E4, D1 | -| 18 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, B3, B5, C4, E4, D1 | -| 19 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, B5, C4, E4, D1 | -| 20 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, B3, C4, E4, D1 | -| 21 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, B3, B5, E4, D1 | -| 22 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo | -| 23 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, A7, B3, B5, C4, E4 | +| 17 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 + E3 (suave: E2 — tendência visível na revisão) | A5, A7, B3, B5, C4, D2 | +| 18 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, B3, B5, C4, E4, D1 | +| 19 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, B3, B5, C4, E4, D1 | +| 20 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, B5, C4, E4, D1 | +| 21 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, B3, C4, E4, D1 | +| 22 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, B3, B5, E4, D1 | +| 23 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo | +| 24 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, A7, B3, B5, C4, E4 | **Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.** | Ordem | Item | Plano | Depende de | | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| 24 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) | -| 25 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 | -| 26 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) | -| 27 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. E5 (Salvador por bairro) fica registrado para depois da eleição, condicionado a decisão de produto. | — | — | +| 25 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) | +| 26 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 | +| 27 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) | +| 28 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. E5 (Salvador por bairro) fica registrado para depois da eleição, condicionado a decisão de produto. | — | — | **Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):** @@ -267,7 +272,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3 e fil - **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_ - **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_ -**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 restantes (leituras por aba / short-circuit de hooks / feed O(n) — só se a agenda permanecer pequena; contadores + locks já entregues), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2, C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa. +**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 restantes (leituras por aba / short-circuit de hooks / feed O(n) — só se a agenda permanecer pequena; contadores + locks já entregues), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2, C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa. ### Itens consolidados/removidos nesta revisão (2026-07-17) diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts index a27f1524..f002a977 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts @@ -2,7 +2,6 @@ import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' -import { ZodError } from 'zod' import { removeSupporterData, @@ -10,12 +9,14 @@ import { } from '@/app/(campaign)/campanha/actions/supporter' import { checkboxFormValue, - FormDataBoundaryError, requiredRelationshipFormValue, - validationFieldErrors, } from '@/lib/formData' import { supporterRemoveSchema, supporterVoteIntentionSchema } from '@/lib/schemas/supporter' import type { SupporterVoteIntention } from '@/lib/schemas/supporter' +import { + mapCampaignFormActionError, + type CampaignFormErrorState, +} from '@/utilities/campaignFormActionError' export type SupporterVoteIntentionFormState = { status?: 'success' @@ -33,6 +34,19 @@ const safeVoteIntentionMessages = [ 'Somente a coordenação pode gerenciar apoiadores.', ] as const +const toMessageOnlyState = ( + mapped: CampaignFormErrorState, +): { message?: string } => { + if (mapped.message) return { message: mapped.message } + if (!mapped.fieldErrors) return {} + + const firstFieldMessage = Object.values(mapped.fieldErrors) + .flat() + .find((message) => message.length > 0) + + return firstFieldMessage ? { message: firstFieldMessage } : {} +} + export const setSupporterVoteIntentionFormAction = async ( _state: SupporterVoteIntentionFormState, formData: FormData, @@ -54,20 +68,13 @@ export const setSupporterVoteIntentionFormAction = async ( voteIntention: supporter.voteIntention ?? undefined, } } catch (error) { - if (error instanceof FormDataBoundaryError) { - return { message: error.message } - } - if (error instanceof ZodError) { - const fieldErrors = validationFieldErrors(error) - return { message: fieldErrors.voteIntention?.[0] ?? fieldErrors.form?.[0] } - } - if ( - error instanceof Error && - safeVoteIntentionMessages.includes(error.message as (typeof safeVoteIntentionMessages)[number]) - ) { - return { message: error.message } - } - return { message: 'Não foi possível salvar a intenção de voto.' } + return toMessageOnlyState( + mapCampaignFormActionError({ + error, + safeMessages: safeVoteIntentionMessages, + genericMessage: 'Não foi possível salvar a intenção de voto.', + }), + ) } } @@ -84,6 +91,11 @@ export const removeSupporterDataFormAction = async ( redirect('/campanha/apoiadores') } catch (error) { if (error instanceof Error && error.message === 'NEXT_REDIRECT') throw error - return { message: 'Não foi possível remover os dados do apoiador.' } + return toMessageOnlyState( + mapCampaignFormActionError({ + error, + genericMessage: 'Não foi possível remover os dados do apoiador.', + }), + ) } } diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx index d3f673c7..6fd22e57 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx @@ -18,13 +18,9 @@ import { EmptyMedia, EmptyTitle, } from '@/components/ui/Empty' -import { isCampaignGeneral, getCoordinatorNucleusIds } from '@/utilities/campaignAccess' +import { isCampaignGeneral } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' -import { - loadAccessibleNucleusOptions, - loadSupporterListOverviewData, - loadSupporterListPageData, -} from '@/utilities/supporterPageData' +import { loadSupportersPageData } from '@/utilities/supporterPageData' import { toSupporterListItemViewModel } from '@/utilities/supporterViewModels' import { buildSupporterFiltersKey, @@ -44,23 +40,12 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro if (!canAccessSupporterArea(user.role)) redirect('/campanha') const rawSearchParams = await searchParams - const [{ result, state, redirectHref }, nucleusOptions, coordinatorNucleusIds] = - await Promise.all([ - loadSupporterListPageData(payload, user, rawSearchParams), - loadAccessibleNucleusOptions(payload, user), - user.role === 'coordenador' - ? getCoordinatorNucleusIds(payload, user.id) - : Promise.resolve(undefined), - ]) - if (redirectHref) redirect(redirectHref) - - const overview = await loadSupporterListOverviewData( + const { result, state, redirectHref, nucleusOptions, overview } = await loadSupportersPageData( payload, user, - state, - result.totalDocs, - coordinatorNucleusIds, + rawSearchParams, ) + if (redirectHref) redirect(redirectHref) return (
diff --git a/src/components/campaign/ActionPlanForm.tsx b/src/components/campaign/ActionPlanForm.tsx index f4fd5539..cd9a7bb4 100644 --- a/src/components/campaign/ActionPlanForm.tsx +++ b/src/components/campaign/ActionPlanForm.tsx @@ -29,6 +29,7 @@ import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershi import type { ActionPlanFormViewModel } from '@/utilities/actionPlanViewModels' import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime' import type { NucleusCoordinatorOption } from '@/utilities/nucleusCoordinatorOptions' +import { fieldError } from '@/utilities/campaignFormFields' export type ActionPlanFormState = { message?: string @@ -65,7 +66,7 @@ export const ActionPlanFormFields = ({ submittedTitle, searchContacts, }: ActionPlanFormFieldsProps) => { - const errorFor = (name: string) => fieldErrors[name]?.[0] + const errorFor = (name: string) => fieldError(fieldErrors, name) const statusIsEditable = !plan || editableStatuses.includes(plan.status as (typeof editableStatuses)[number]) const [status, setStatus] = useState<(typeof editableStatuses)[number]>( diff --git a/src/components/campaign/ActionPlanUpdateForm.tsx b/src/components/campaign/ActionPlanUpdateForm.tsx index 9bb5ad7b..65af121a 100644 --- a/src/components/campaign/ActionPlanUpdateForm.tsx +++ b/src/components/campaign/ActionPlanUpdateForm.tsx @@ -11,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Field, FieldError, FieldLabel } from '@/components/ui/field' import { Spinner } from '@/components/ui/Spinner' import { Textarea } from '@/components/ui/textarea' +import { fieldError } from '@/utilities/campaignFormFields' export const ActionPlanUpdateForm = ({ planId }: { planId: number }) => { const router = useRouter() @@ -24,7 +25,7 @@ export const ActionPlanUpdateForm = ({ planId }: { planId: number }) => { router.refresh() }, [router, state.message, state.status]) - const bodyError = state.fieldErrors?.body?.[0] + const bodyError = fieldError(state.fieldErrors, 'body') return ( diff --git a/src/components/campaign/LeadershipPrimaryContactAction.tsx b/src/components/campaign/LeadershipPrimaryContactAction.tsx index f107fbf2..3cd10090 100644 --- a/src/components/campaign/LeadershipPrimaryContactAction.tsx +++ b/src/components/campaign/LeadershipPrimaryContactAction.tsx @@ -12,6 +12,7 @@ import { import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' import { Spinner } from '@/components/ui/Spinner' +import { fieldError } from '@/utilities/campaignFormFields' export const PrimaryContactFormFields = ({ nucleusId, @@ -22,8 +23,8 @@ export const PrimaryContactFormFields = ({ contactId: number state: LeadershipFormState }) => { - const nucleusError = state.fieldErrors?.nucleus?.[0] - const contactError = state.fieldErrors?.contact?.[0] + const nucleusError = fieldError(state.fieldErrors, 'nucleus') + const contactError = fieldError(state.fieldErrors, 'contact') const hasError = Boolean(state.message || nucleusError || contactError) return ( @@ -83,11 +84,7 @@ export const LeadershipPrimaryContactAction = ({ return (
{ - const errorFor = (name: string) => fieldErrors[name]?.[0] + const errorFor = (name: string) => fieldError(fieldErrors, name) return ( diff --git a/src/components/campaign/NucleusIntelligenceDialog.tsx b/src/components/campaign/NucleusIntelligenceDialog.tsx index e79f3744..58ddf28b 100644 --- a/src/components/campaign/NucleusIntelligenceDialog.tsx +++ b/src/components/campaign/NucleusIntelligenceDialog.tsx @@ -37,6 +37,7 @@ import type { PrimaryContactOption, } from '@/utilities/primaryContactPageData' import type { StaffNucleusTabsViewModel } from '@/utilities/nucleusViewModels' +import { fieldError } from '@/utilities/campaignFormFields' type EditableProfile = StaffNucleusTabsViewModel['voterProfiles'][number] @@ -120,6 +121,8 @@ export const NucleusIntelligenceDialog = ({ onPendingChange?.(pending) }, [onPendingChange, pending]) + const primaryContactError = fieldError(state.fieldErrors, 'primaryContact') + const updateProfile = (index: number, field: keyof EditableProfile, value: string) => { setProfiles((current) => current.map((profile, profileIndex) => @@ -403,9 +406,7 @@ export const NucleusIntelligenceDialog = ({ search={searchPrimaryContacts} /> Apenas lideranças engajadas podem ser escolhidas. - {state.fieldErrors?.primaryContact?.[0] ? ( - {state.fieldErrors.primaryContact[0]} - ) : null} + {primaryContactError ? {primaryContactError} : null} diff --git a/src/components/campaign/NucleusTerritoryAndZonesFields.tsx b/src/components/campaign/NucleusTerritoryAndZonesFields.tsx index ca1295bd..989f4469 100644 --- a/src/components/campaign/NucleusTerritoryAndZonesFields.tsx +++ b/src/components/campaign/NucleusTerritoryAndZonesFields.tsx @@ -22,6 +22,7 @@ import { territoryComboboxOptions, } from '@/utilities/territoryComboboxOptions' import { sortedUniqueZoneNumbers } from '@/utilities/tseZone' +import { fieldError } from '@/utilities/campaignFormFields' type TerritoryAndZonesValues = { regions?: string[] | null @@ -71,7 +72,7 @@ export const NucleusTerritoryAndZonesFields = ({ const [cityError, setCityError] = useState() const [neighborhoodError, setNeighborhoodError] = useState() - const errorFor = (name: string) => fieldErrors[name]?.[0] + const errorFor = (name: string) => fieldError(fieldErrors, name) const derivedRegions = useMemo(() => territoriesForCities(cities), [cities]) const displayRegions = cities.length > 0 ? derivedRegions : regions const regionsAreDerived = cities.length > 0 diff --git a/src/components/campaign/NucleusTerritoryFields.tsx b/src/components/campaign/NucleusTerritoryFields.tsx index 622cb757..849d2b39 100644 --- a/src/components/campaign/NucleusTerritoryFields.tsx +++ b/src/components/campaign/NucleusTerritoryFields.tsx @@ -20,6 +20,7 @@ import { allMunicipalityComboboxOptions, territoryComboboxOptions, } from '@/utilities/territoryComboboxOptions' +import { fieldError } from '@/utilities/campaignFormFields' type TerritoryValues = { regions?: string[] | null @@ -73,7 +74,7 @@ export const CampaignTerritoryFields = ({ const [cityError, setCityError] = useState() const [neighborhoodError, setNeighborhoodError] = useState() - const errorFor = (name: string) => fieldErrors[name]?.[0] + const errorFor = (name: string) => fieldError(fieldErrors, name) const derivedRegions = useMemo(() => territoriesForCities(cities), [cities]) const displayRegions = cities.length > 0 ? derivedRegions : regions const regionsAreDerived = cities.length > 0 diff --git a/src/components/campaign/VoteEstimateDialog.tsx b/src/components/campaign/VoteEstimateDialog.tsx index e01545dc..d7b26eae 100644 --- a/src/components/campaign/VoteEstimateDialog.tsx +++ b/src/components/campaign/VoteEstimateDialog.tsx @@ -25,6 +25,7 @@ import { Input } from '@/components/ui/input' import { Spinner } from '@/components/ui/Spinner' import { Textarea } from '@/components/ui/textarea' import type { CampaignUser } from '@/payload-types' +import { fieldError } from '@/utilities/campaignFormFields' type VoteEstimateDialogProps = { nucleusId: number @@ -80,8 +81,8 @@ export const VoteEstimateActionDialog = ({ const successfulCloseRef = useRef(false) const requiresNote = isEdit || (isReview && proposedEstimate != null && Number(estimate) !== proposedEstimate) - const estimateError = state.fieldErrors?.estimate?.[0] - const noteError = state.fieldErrors?.confirmationNote?.[0] + const estimateError = fieldError(state.fieldErrors, 'estimate') + const noteError = fieldError(state.fieldErrors, 'confirmationNote') const fieldIdSuffix = mode useEffect(() => { diff --git a/src/utilities/supporterListFilters.ts b/src/utilities/supporterListFilters.ts new file mode 100644 index 00000000..abcdee72 --- /dev/null +++ b/src/utilities/supporterListFilters.ts @@ -0,0 +1,108 @@ +import { sql } from '@payloadcms/db-postgres' +import type { Where } from 'payload' + +import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery' +import { normalizeBrazilianPhone } from '@/utilities/phone' +import type { SupporterListState } from '@/utilities/supporterUi' + +export type SupporterSearchTerms = { + q: string + normalizedPhone: string | null + phoneDigits: string | null +} + +export type AggregateSqlConditions = { + conditions: ReturnType[] + needsContactJoin: boolean +} + +export const buildSupporterSearchTerms = (rawQ: string): SupporterSearchTerms | null => { + if (!isContactSearchQueryReady(rawQ)) return null + + const { trimmed, digits } = normalizeContactSearchQuery(rawQ) + const normalizedPhone = normalizeBrazilianPhone(trimmed) + const phoneDigits = !normalizedPhone && digits ? digits : null + + return { q: trimmed, normalizedPhone, phoneDigits } +} + +const buildPayloadSearchWhere = (terms: SupporterSearchTerms): Where => { + const searchFilters: Where[] = [ + { 'contact.name': { contains: terms.q } }, + { 'contact.city': { contains: terms.q } }, + ] + + if (terms.normalizedPhone) { + searchFilters.push({ 'contact.phone': { equals: terms.normalizedPhone } }) + } else if (terms.phoneDigits) { + searchFilters.push({ 'contact.phone': { contains: terms.phoneDigits } }) + } + + return { or: searchFilters } +} + +const buildSqlSearchCondition = (terms: SupporterSearchTerms): ReturnType => { + const searchTerms: ReturnType[] = [ + sql`"contact"."name" ILIKE ${`%${terms.q}%`}`, + sql`"contact"."city" ILIKE ${`%${terms.q}%`}`, + ] + + if (terms.normalizedPhone) { + searchTerms.push(sql`"contact"."phone" = ${terms.normalizedPhone}`) + } else if (terms.phoneDigits) { + searchTerms.push(sql`"contact"."phone" ILIKE ${`%${terms.phoneDigits}%`}`) + } + + return sql`(${sql.join(searchTerms, sql` OR `)})` +} + +export const toPayloadWhere = (state: SupporterListState): Where => { + const filters: Where[] = [] + + if (state.q) { + const searchTerms = buildSupporterSearchTerms(state.q) + if (searchTerms) { + filters.push(buildPayloadSearchWhere(searchTerms)) + } + } + + if (state.voteIntention) { + filters.push({ voteIntention: { equals: state.voteIntention } }) + } + if (state.city) { + filters.push({ 'contact.city': { equals: state.city } }) + } + if (state.nucleus) { + filters.push({ nucleus: { equals: state.nucleus } }) + } + + return filters.length ? { and: filters } : {} +} + +export const toAggregateSqlConditions = (state: SupporterListState): AggregateSqlConditions => { + const conditions: ReturnType[] = [] + let needsContactJoin = false + + if (state.voteIntention) { + conditions.push(sql`"supporter"."vote_intention" = ${state.voteIntention}`) + } + + if (state.nucleus) { + conditions.push(sql`"supporter"."nucleus_id" = ${state.nucleus}`) + } + + if (state.city) { + needsContactJoin = true + conditions.push(sql`"contact"."city" = ${state.city}`) + } + + if (state.q) { + const searchTerms = buildSupporterSearchTerms(state.q) + if (searchTerms) { + needsContactJoin = true + conditions.push(buildSqlSearchCondition(searchTerms)) + } + } + + return { conditions, needsContactJoin } +} diff --git a/src/utilities/supporterListOverviewAggregate.ts b/src/utilities/supporterListOverviewAggregate.ts index 1eef8a88..00115ba2 100644 --- a/src/utilities/supporterListOverviewAggregate.ts +++ b/src/utilities/supporterListOverviewAggregate.ts @@ -6,7 +6,7 @@ import type { Payload } from 'payload' import type { CampaignUser } from '@/payload-types' import { getCoordinatorNucleusIds, isCampaignGeneral } from '@/utilities/campaignAccess' import { drizzleResultRows } from '@/utilities/drizzleBulk' -import { normalizeBrazilianPhone } from '@/utilities/phone' +import { toAggregateSqlConditions } from '@/utilities/supporterListFilters' import type { SupporterListState } from '@/utilities/supporterUi' import type { SupporterListOverviewViewModel } from '@/utilities/supporterViewModels' @@ -36,8 +36,8 @@ const buildAggregateSql = ( state: SupporterListState, access: AccessConstraint, ): ReturnType => { - const conditions: ReturnType[] = [] - const needsContactJoin = Boolean(state.q || state.city) + const { conditions: filterConditions, needsContactJoin } = toAggregateSqlConditions(state) + const conditions = [...filterConditions] if (access.kind === 'nucleusSet') { if (access.ids.length === 0) { @@ -53,33 +53,6 @@ const buildAggregateSql = ( } } - if (state.voteIntention) { - conditions.push(sql`"supporter"."vote_intention" = ${state.voteIntention}`) - } - - if (state.nucleus) { - conditions.push(sql`"supporter"."nucleus_id" = ${state.nucleus}`) - } - - if (state.city) { - conditions.push(sql`"contact"."city" = ${state.city}`) - } - - if (state.q) { - const searchTerms: ReturnType[] = [ - sql`"contact"."name" ILIKE ${`%${state.q}%`}`, - sql`"contact"."city" ILIKE ${`%${state.q}%`}`, - ] - const normalizedPhone = normalizeBrazilianPhone(state.q) - if (normalizedPhone) { - searchTerms.push(sql`"contact"."phone" = ${normalizedPhone}`) - } else if (/\d/.test(state.q)) { - const digits = state.q.replace(/\D/g, '') - if (digits) searchTerms.push(sql`"contact"."phone" ILIKE ${`%${digits}%`}`) - } - conditions.push(sql`(${sql.join(searchTerms, sql` OR `)})`) - } - const whereClause = conditions.length > 0 ? sql` WHERE ${sql.join(conditions, sql` AND `)}` : sql`` diff --git a/src/utilities/supporterPageData.ts b/src/utilities/supporterPageData.ts index 8c9be655..6193c5e4 100644 --- a/src/utilities/supporterPageData.ts +++ b/src/utilities/supporterPageData.ts @@ -1,6 +1,7 @@ import type { Payload } from 'payload' import type { CampaignUser, Consent, ElectoralNucleus, Supporter } from '@/payload-types' +import { getCoordinatorNucleusIds } from '@/utilities/campaignAccess' import { getSupporterRegistrationConsent, getSupporterVoteIntentionConsent, @@ -54,17 +55,36 @@ const nucleusOptionsSelect = { export const loadAccessibleNucleusOptions = async ( payload: Pick, user: CampaignUser, + coordinatorNucleusIds?: number[], ): Promise => { - const result = await payload.find({ - collection: 'electoralNucleus', - where: { status: { equals: 'ativo' } }, - depth: 0, - pagination: false, - sort: 'name', - select: nucleusOptionsSelect, - user, - overrideAccess: false, - }) + let result: { docs: ElectoralNucleus[] } + + if (user.role === 'coordenador' && coordinatorNucleusIds !== undefined) { + if (coordinatorNucleusIds.length === 0) return [] + + result = await payload.find({ + collection: 'electoralNucleus', + where: { + and: [{ status: { equals: 'ativo' } }, { id: { in: coordinatorNucleusIds } }], + }, + depth: 0, + pagination: false, + sort: 'name', + select: nucleusOptionsSelect, + overrideAccess: true, + }) + } else { + result = await payload.find({ + collection: 'electoralNucleus', + where: { status: { equals: 'ativo' } }, + depth: 0, + pagination: false, + sort: 'name', + select: nucleusOptionsSelect, + user, + overrideAccess: false, + }) + } return result.docs.map((nucleus) => { const doc = nucleus as ElectoralNucleus @@ -111,6 +131,51 @@ export const loadSupporterListOverviewData = async ( ): Promise => computeSupporterListOverviewAggregate(payload, user, state, total, coordinatorNucleusIds) +export type SupportersPageData = { + result: Awaited>['result'] + state: SupporterListState + redirectHref?: string + nucleusOptions: SupporterNucleusOption[] + overview: SupporterListOverviewViewModel | null + coordinatorNucleusIds?: number[] +} + +export const loadSupportersPageData = async ( + payload: Payload, + user: CampaignUser, + searchParams: Promise | SupporterListSearchParams, +): Promise => { + const coordinatorPromise = + user.role === 'coordenador' + ? getCoordinatorNucleusIds(payload, user.id) + : Promise.resolve(undefined) + + const [{ result, state, redirectHref }, coordinatorNucleusIds] = await Promise.all([ + loadSupporterListPageData(payload, user, searchParams), + coordinatorPromise, + ]) + + const [nucleusOptions, overview] = await Promise.all([ + loadAccessibleNucleusOptions(payload, user, coordinatorNucleusIds), + loadSupporterListOverviewData( + payload, + user, + state, + result.totalDocs, + coordinatorNucleusIds, + ), + ]) + + return { + result, + state, + redirectHref, + nucleusOptions, + overview, + coordinatorNucleusIds, + } +} + export const loadSupporterDetailPageData = async ( payload: Pick, user: CampaignUser, diff --git a/src/utilities/supporterUi.ts b/src/utilities/supporterUi.ts index 18c6c32d..1fb7501d 100644 --- a/src/utilities/supporterUi.ts +++ b/src/utilities/supporterUi.ts @@ -14,7 +14,7 @@ import { strictDecimalInteger, type RawSearchParams as CampaignListRawSearchParams, } from '@/utilities/campaignListUrl' -import { normalizeBrazilianPhone } from '@/utilities/phone' +import { toPayloadWhere } from '@/utilities/supporterListFilters' export const supporterPageSize = 25 @@ -63,35 +63,7 @@ export const parseSupporterListParams = (params: RawSearchParams): SupporterList } } -export const buildSupporterListWhere = (state: SupporterListState): Where => { - const filters: Where[] = [] - - if (state.q) { - const searchFilters: Where[] = [ - { 'contact.name': { contains: state.q } }, - { 'contact.city': { contains: state.q } }, - ] - const normalizedPhone = normalizeBrazilianPhone(state.q) - if (normalizedPhone) { - searchFilters.push({ 'contact.phone': { equals: normalizedPhone } }) - } else if (/\d/.test(state.q)) { - searchFilters.push({ 'contact.phone': { contains: state.q.replace(/\D/g, '') } }) - } - filters.push({ or: searchFilters }) - } - - if (state.voteIntention) { - filters.push({ voteIntention: { equals: state.voteIntention } }) - } - if (state.city) { - filters.push({ 'contact.city': { equals: state.city } }) - } - if (state.nucleus) { - filters.push({ nucleus: { equals: state.nucleus } }) - } - - return filters.length ? { and: filters } : {} -} +export const buildSupporterListWhere = (state: SupporterListState): Where => toPayloadWhere(state) export const buildSupporterListSearchParams = ( state: SupporterListState, diff --git a/tests/int/campaignSupporter.int.spec.ts b/tests/int/campaignSupporter.int.spec.ts index 238e9d20..0595fa37 100644 --- a/tests/int/campaignSupporter.int.spec.ts +++ b/tests/int/campaignSupporter.int.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { beforeAll, describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it, vi } from 'vitest' import { getPayload, type Payload } from 'payload' import { @@ -16,7 +16,8 @@ import { SUPPORTER_REGISTRATION_CONSENT_KEY, SUPPORTER_VOTE_INTENTION_CONSENT_KEY, } from '@/utilities/campaignConsent' -import { loadSupporterListOverviewData } from '@/utilities/supporterPageData' +import { loadSupporterListOverviewData, loadSupportersPageData } from '@/utilities/supporterPageData' +import { buildSupporterListWhere } from '@/utilities/supporterUi' import { installCampaignFixtures } from '../helpers/campaignFixtures' @@ -383,8 +384,9 @@ describe('campaign supporter domain', () => { { voteIntention: 'outro' }, { voteIntention: null }, ] + const searchTag = fixtures.value('OverviewKpi') for (const { voteIntention } of intentions) { - const contact = await fixtures.createContact() + const contact = await fixtures.createContact({ name: searchTag }) await fixtures.createSupporter({ contact: contact.id, consent: registration.id, @@ -395,11 +397,20 @@ describe('campaign supporter domain', () => { }) } + const list = await payload.find({ + collection: 'supporter', + where: buildSupporterListWhere({ page: 1, q: searchTag }), + depth: 0, + pagination: false, + user: geral, + overrideAccess: false, + }) + const overview = await loadSupporterListOverviewData( payload, geral, - { page: 1 }, - intentions.length, + { page: 1, q: searchTag }, + list.totalDocs, ) expect(overview).not.toBeNull() expect(overview!.total).toBe(intentions.length) @@ -456,4 +467,117 @@ describe('campaign supporter domain', () => { expect(filtered).not.toBeNull() expect(filtered!.total).toBe(1) }) + + it('ignores single-character search queries in list and overview filters', async () => { + const fixtures = campaignFixtures() + const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY) + const geral = await fixtures.createCampaignUser('geral') + + const uniqueName = fixtures.value('ApoiadorUnico') + const contact = await fixtures.createContact({ name: uniqueName }) + await fixtures.createSupporter({ + contact: contact.id, + consent: registration.id, + consentContentHash: 'hash', + consentedAt: new Date().toISOString(), + createdBy: geral.id, + }) + + const shortQuery = uniqueName.slice(0, 1) + const listWhere = buildSupporterListWhere({ page: 1, q: shortQuery }) + const list = await payload.find({ + collection: 'supporter', + where: listWhere, + depth: 0, + pagination: false, + user: geral, + overrideAccess: false, + }) + + expect(list.totalDocs).toBeGreaterThanOrEqual(1) + + const overview = await loadSupporterListOverviewData( + payload, + geral, + { page: 1, q: shortQuery }, + list.totalDocs, + ) + expect(overview).not.toBeNull() + expect(overview!.total).toBe(list.totalDocs) + }) + + it('keeps list and overview totals aligned for multi-character search', async () => { + const fixtures = campaignFixtures() + const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY) + const geral = await fixtures.createCampaignUser('geral') + + const uniqueName = fixtures.value('ApoiadorBusca') + const contact = await fixtures.createContact({ name: uniqueName }) + await fixtures.createSupporter({ + contact: contact.id, + consent: registration.id, + consentContentHash: 'hash', + consentedAt: new Date().toISOString(), + createdBy: geral.id, + }) + + const q = uniqueName.slice(0, 8) + const list = await payload.find({ + collection: 'supporter', + where: buildSupporterListWhere({ page: 1, q }), + depth: 0, + pagination: false, + user: geral, + overrideAccess: false, + }) + + expect(list.totalDocs).toBeGreaterThanOrEqual(1) + + const overview = await loadSupporterListOverviewData( + payload, + geral, + { page: 1, q }, + list.totalDocs, + ) + expect(overview).not.toBeNull() + expect(overview!.total).toBe(list.totalDocs) + expect(overview!.certoAndTende + overview!.indeciso).toBeLessThanOrEqual(list.totalDocs) + }) + + it('loads supporters page data with a single coordinator nucleus lookup', async () => { + const fixtures = campaignFixtures() + await ensureSupporterConsents(fixtures) + const coordenador = await fixtures.createCampaignUser('coordenador') + const assigned = await fixtures.createNucleus({ coordinators: [coordenador.id] }) + const registration = await ensureConsentByKey(fixtures, SUPPORTER_REGISTRATION_CONSENT_KEY) + + const contact = await fixtures.createContact() + await fixtures.createSupporter({ + contact: contact.id, + nucleus: assigned.id, + consent: registration.id, + consentContentHash: 'hash', + consentedAt: new Date().toISOString(), + createdBy: coordenador.id, + }) + + const campaignAccess = await import('@/utilities/campaignAccess') + const originalGetCoordinatorNucleusIds = campaignAccess.getCoordinatorNucleusIds + let lookupCount = 0 + const spy = vi.spyOn(campaignAccess, 'getCoordinatorNucleusIds') + spy.mockImplementation(async (...args) => { + lookupCount += 1 + return originalGetCoordinatorNucleusIds(...args) + }) + + try { + const pageData = await loadSupportersPageData(payload, coordenador, {}) + expect(lookupCount).toBe(1) + expect(pageData.coordinatorNucleusIds).toContain(assigned.id) + expect(pageData.nucleusOptions.some((option) => option.id === assigned.id)).toBe(true) + expect(pageData.result.totalDocs).toBeGreaterThanOrEqual(1) + } finally { + spy.mockRestore() + } + }) }) diff --git a/tests/int/supporterListFilters.int.spec.ts b/tests/int/supporterListFilters.int.spec.ts new file mode 100644 index 00000000..ead64a8c --- /dev/null +++ b/tests/int/supporterListFilters.int.spec.ts @@ -0,0 +1,79 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + buildSupporterSearchTerms, + toAggregateSqlConditions, + toPayloadWhere, +} from '@/utilities/supporterListFilters' + +describe('supporterListFilters', () => { + it('ignores search terms shorter than the contact search minimum', () => { + expect(buildSupporterSearchTerms('a')).toBeNull() + expect(toPayloadWhere({ page: 1, q: 'a' })).toEqual({}) + expect(toAggregateSqlConditions({ page: 1, q: 'a' }).needsContactJoin).toBe(false) + }) + + it('builds matching payload and SQL search conditions for text queries', () => { + const state = { page: 1, q: 'ana' } + const payloadWhere = toPayloadWhere(state) + const aggregate = toAggregateSqlConditions(state) + + expect(payloadWhere).toEqual({ + and: [ + { + or: [ + { 'contact.name': { contains: 'ana' } }, + { 'contact.city': { contains: 'ana' } }, + ], + }, + ], + }) + expect(aggregate.needsContactJoin).toBe(true) + expect(aggregate.conditions).toHaveLength(1) + }) + + it('normalizes phone search terms consistently', () => { + const terms = buildSupporterSearchTerms('(71) 98888-7777') + expect(terms).toEqual({ + q: '(71) 98888-7777', + normalizedPhone: '71988887777', + phoneDigits: null, + }) + + const payloadWhere = toPayloadWhere({ page: 1, q: '(71) 98888-7777' }) + expect(payloadWhere).toEqual({ + and: [ + { + or: [ + { 'contact.name': { contains: '(71) 98888-7777' } }, + { 'contact.city': { contains: '(71) 98888-7777' } }, + { 'contact.phone': { equals: '71988887777' } }, + ], + }, + ], + }) + }) + + it('applies vote intention, city, and nucleus filters in both adapters', () => { + const state = { + page: 1, + voteIntention: 'indeciso' as const, + city: 'Salvador', + nucleus: 42, + } + + expect(toPayloadWhere(state)).toEqual({ + and: [ + { voteIntention: { equals: 'indeciso' } }, + { 'contact.city': { equals: 'Salvador' } }, + { nucleus: { equals: 42 } }, + ], + }) + + const aggregate = toAggregateSqlConditions(state) + expect(aggregate.needsContactJoin).toBe(true) + expect(aggregate.conditions).toHaveLength(3) + }) +})