Deliver C9 scale/DRY for supporters and register C10 follow-ups.
This commit is contained in:
parent
1e23b27b4f
commit
ced1b71771
20 changed files with 632 additions and 178 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<br/>supporterUi + aggregate SQL"]
|
||||
F1 --> F2["Fase 2 — contactSearchQuery + loader página<br/>1× núcleos coordenador"]
|
||||
F2 --> F3["Fase 3 — formActions detalhe apoiador<br/>contrato message preservado"]
|
||||
F1["Fase 1 — Filtro unificado<br/>supporterListFilters"]
|
||||
F1 --> F2["Fase 2 — contactSearchQuery + loader página<br/>loadSupportersPageData"]
|
||||
F2 --> F3["Fase 3 — formActions detalhe apoiador<br/>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`.
|
||||
|
|
|
|||
109
docs/plans/escala-dry-pos-c9.md
Normal file
109
docs/plans/escala-dry-pos-c9.md
Normal file
|
|
@ -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<br/>(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.
|
||||
|
|
@ -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<br/>(import/listas/forms) ✓"]
|
||||
C3["C3 Eventos / agenda de mobilização ✓"]
|
||||
C7["C7 Escala e DRY pós-C3<br/>(parcial: contadores/locks/FormData)"]
|
||||
C8["C8 Escala e DRY pós-C6<br/>(locks bulk / leituras / forms DRY)"]
|
||||
C9["C9 Escala e DRY pós-C8<br/>(filtros unificados / loader / forms)"]
|
||||
C8["C8 Escala e DRY pós-C6<br/>(locks bulk / leituras / forms DRY) ✓"]
|
||||
C9["C9 Escala e DRY pós-C8<br/>(filtros unificados / loader / forms) ✓"]
|
||||
C10["C10 Escala e DRY pós-C9<br/>(access dedup / scope / errorProps)"]
|
||||
C4["C4 Demandas"]
|
||||
C5["C5 Operação dia D / GOTV<br/>(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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<unknown>,
|
||||
): { 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.',
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
|
||||
|
|
|
|||
|
|
@ -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]>(
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<form
|
||||
action={action}
|
||||
aria-describedby={
|
||||
state.message || state.fieldErrors?.nucleus || state.fieldErrors?.contact
|
||||
? 'primary-contact-form-error'
|
||||
: undefined
|
||||
}
|
||||
aria-describedby={hasError ? 'primary-contact-form-error' : undefined}
|
||||
>
|
||||
<PrimaryContactFormFields
|
||||
nucleusId={nucleusId}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
|||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { NucleusCoordinatorOption } from '@/utilities/nucleusCoordinatorOptions'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
import { organizationKindLabels, sectorKindLabels } from '@/utilities/nucleusUi'
|
||||
import type { NucleusFormViewModel } from '@/utilities/nucleusViewModels'
|
||||
|
||||
|
|
@ -81,7 +82,7 @@ export const NucleusFormFields = ({
|
|||
fieldErrors = {},
|
||||
submittedName,
|
||||
}: NucleusFormFieldsProps) => {
|
||||
const errorFor = (name: string) => fieldErrors[name]?.[0]
|
||||
const errorFor = (name: string) => fieldError(fieldErrors, name)
|
||||
|
||||
return (
|
||||
<FieldGroup>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
<FieldDescription>Apenas lideranças engajadas podem ser escolhidas.</FieldDescription>
|
||||
{state.fieldErrors?.primaryContact?.[0] ? (
|
||||
<FieldError>{state.fieldErrors.primaryContact[0]}</FieldError>
|
||||
) : null}
|
||||
{primaryContactError ? <FieldError>{primaryContactError}</FieldError> : null}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
const [neighborhoodError, setNeighborhoodError] = useState<string>()
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
const [neighborhoodError, setNeighborhoodError] = useState<string>()
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
108
src/utilities/supporterListFilters.ts
Normal file
108
src/utilities/supporterListFilters.ts
Normal file
|
|
@ -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<typeof sql>[]
|
||||
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<typeof sql> => {
|
||||
const searchTerms: ReturnType<typeof sql>[] = [
|
||||
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<typeof sql>[] = []
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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<typeof sql> => {
|
||||
const conditions: ReturnType<typeof sql>[] = []
|
||||
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<typeof sql>[] = [
|
||||
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``
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
coordinatorNucleusIds?: number[],
|
||||
): Promise<SupporterNucleusOption[]> => {
|
||||
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<SupporterListOverviewViewModel | null> =>
|
||||
computeSupporterListOverviewAggregate(payload, user, state, total, coordinatorNucleusIds)
|
||||
|
||||
export type SupportersPageData = {
|
||||
result: Awaited<ReturnType<typeof loadSupporterListPageData>>['result']
|
||||
state: SupporterListState
|
||||
redirectHref?: string
|
||||
nucleusOptions: SupporterNucleusOption[]
|
||||
overview: SupporterListOverviewViewModel | null
|
||||
coordinatorNucleusIds?: number[]
|
||||
}
|
||||
|
||||
export const loadSupportersPageData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
searchParams: Promise<SupporterListSearchParams> | SupporterListSearchParams,
|
||||
): Promise<SupportersPageData> => {
|
||||
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<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
79
tests/int/supporterListFilters.int.spec.ts
Normal file
79
tests/int/supporterListFilters.int.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue