From 1e23b27b4fd00e92ac45c765900a2c9697376bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 19 Jul 2026 00:30:16 -0300 Subject: [PATCH] Deliver C8 scale/DRY for supporters and register C9 follow-ups. Bulk import and list paths get single-round-trip locks, drizzle returning reads, pg_trgm search, shared drizzleBulk helpers, and consolidated campaign formActions; simplify debts land as C9 in the roadmap. --- docs/plans/escala-dry-pos-c2.md | 2 +- docs/plans/escala-dry-pos-c6.md | 16 +- docs/plans/escala-dry-pos-c8.md | 108 ++++++++++++ docs/roadmap.md | 43 +++-- .../campanha/(app)/apoiadores/page.tsx | 25 ++- .../coordinatorAssignmentFormActions.ts | 28 ++- .../[slug]/nucleusIntelligenceFormActions.ts | 15 +- .../[slug]/nucleusUpdateFormActions.ts | 16 +- .../nucleos/[slug]/voteEstimateFormActions.ts | 35 ++-- .../campanha/(app)/nucleos/formActions.ts | 24 +-- .../(app)/planos/[slug]/updateFormActions.ts | 35 ++-- .../campanha/(app)/planos/formActions.ts | 23 +-- .../campanha/convite/[token]/formActions.ts | 21 +-- .../campaign/CampaignInviteForm.tsx | 86 ++++------ src/components/campaign/NucleusUpdateForm.tsx | 21 +-- .../20260719_020000_add_contact_trgm_index.ts | 26 +++ src/migrations/index.ts | 8 +- src/utilities/campaignAccess.ts | 69 +++++--- src/utilities/campaignFormActionError.ts | 11 +- src/utilities/campaignInviteRepository.ts | 18 +- src/utilities/drizzleBulk.ts | 63 +++++++ src/utilities/electionResultsImport.ts | 30 +--- src/utilities/postgresTransactionLocks.ts | 13 +- src/utilities/supporterImportBulk.ts | 161 ++++++++++-------- .../supporterListOverviewAggregate.ts | 51 +++--- src/utilities/supporterPageData.ts | 4 +- tests/int/campaignSupporter.int.spec.ts | 23 ++- .../int/postgresTransactionLocks.int.spec.ts | 26 ++- 28 files changed, 597 insertions(+), 404 deletions(-) create mode 100644 docs/plans/escala-dry-pos-c8.md create mode 100644 src/migrations/20260719_020000_add_contact_trgm_index.ts create mode 100644 src/utilities/drizzleBulk.ts diff --git a/docs/plans/escala-dry-pos-c2.md b/docs/plans/escala-dry-pos-c2.md index 0f228d71..ac42ef82 100644 --- a/docs/plans/escala-dry-pos-c2.md +++ b/docs/plans/escala-dry-pos-c2.md @@ -155,7 +155,7 @@ Todas as cinco fases foram implementadas e validadas localmente (`tsc --noEmit`, - **Bulk insert deve rodar na txn Payload existente, não numa txn drizzle nova.** O seed TSE (`electionResultsImport.ts`) abre `dbAdapter.drizzle.transaction(...)` própria; para C4 isso quebraria a atomicidade com os locks advisory. Usado `getPostgresTransactionDatabase(payload, req)` para pegar a sessão drizzle vinculada à txn Payload. - **`skipContactPhoneInvariant` é fail-closed por txn, não por introspecção de lock.** Não há maneira barata de perguntar ao PG "seguro o lock X?"; o proxy usado é `req.transactionID` ativo (o lock `pg_advisory_xact_lock` é xact-level e só existe dentro de uma txn). Sem txn + flag setado → throw 500. - **`supporter.beforeChange` (coexistência com liderança) é no-op para import sem núcleo.** O hook só dispara quando `contactID` e `nucleusID` estão presentes; import cria supporters sem núcleo, então o bypass via drizzle insert é seguro. `createdBy` é setado explicitamente no row bulk. -- **Escopo do mapper de form errors:** `ActionPlanPagination` e os forms de planos também consomem `campaignListUrl`/`CampaignListPagination`, mas o mapper de form actions de planos ficou para C7 (escopo de copy/mensagens diferente). +- **Escopo do mapper de form errors:** `ActionPlanPagination` e os forms de planos também consomem `campaignListUrl`/`CampaignListPagination`. O mapper de form actions de planos (`planos/formActions.ts`, `planos/[slug]/updateFormActions.ts`) ficou de fora do escopo original do C7 (copy/mensagens diferentes) mas foi consolidado pelo C8 (`escala-dry-pos-c6.md`), junto com os demais `formActions` restantes — C7 Fase 3 manteve apenas os helpers de FormData/URL de território do `actionPlan`. ## Simplify (2026-07-19) diff --git a/docs/plans/escala-dry-pos-c6.md b/docs/plans/escala-dry-pos-c6.md index f03b1a51..0b6695f7 100644 --- a/docs/plans/escala-dry-pos-c6.md +++ b/docs/plans/escala-dry-pos-c6.md @@ -1,6 +1,6 @@ # Escala e DRY pós-C6 (apoiadores / import / listas) -Status: rascunho +Status: implementado (pendente merge em `main`) Atualizado em: 2026-07-19 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C8) Responsável: — @@ -118,3 +118,17 @@ flowchart TD - `src/utilities/campaignAccess.ts` — `getAccessibleNucleusIds` - `src/utilities/campaignFormActionError.ts` / `campaignFormFields.ts` — helpers adotados por 2 forms - AGENTS.md — `overrideAccess: false`, transações, locks advisory xact-level, naming + +## Simplify (2026-07-19) + +Duas passagens `/simplify` sobre o diff do C8 (pré- e pós-rebase em `main` com C7) aplicaram limpezas pontuais sem mudar comportamento: `safeMessages` opcional em `mapCampaignFormActionError`, `drizzleResultRows` reutilizado em `campaignInviteRepository`, chunk de `IN` nas leituras drizzle do bulk import, `select: { id: true }` via `DynamicFind` em `getCoordinatorNucleusIds`, prefetch de núcleos do coordenador para o overview, `errorProps` no `CampaignInviteForm`/`NucleusUpdateForm`, e hoisting de erros de campo. Validado com `tsc --noEmit`, `pnpm lint`, `pnpm test:int`, `pnpm build` e scan Aikido. + +Os débitos que os revisores marcaram como importantes e maiores que cleanup foram registrados como item **C9** — [escala-dry-pos-c8.md](escala-dry-pos-c8.md): + +1. **Filtros duplicados** — `buildSupporterListWhere` (Payload) vs `buildAggregateSql` (SQL cru); risco de drift entre KPIs e lista. +2. **`contactSearchQuery` não wireado no aggregate** — overview ainda dispara `ILIKE` para `q` de 1 caractere apesar do `pg_trgm` e do guard de C7. +3. **Lookup de núcleos do coordenador repetido** — até 3× por navegação em `/campanha/apoiadores` (list access + options + overview prefetch). +4. **`apoiadores/[id]/formActions.ts` fora do mapper** — contrato `message` vs `fieldErrors` exige desenho antes de migrar. +5. **Componentes de form com `fieldError` local** — `NucleusForm`, `ActionPlanForm`, `NucleusTerritoryFields`, `VoteEstimateDialog`, etc. + +**Explicitamente fora (tradeoffs aceitos no C8):** waterfall lista→overview (precisa de `totalDocs`); unificar lista+aggregate numa query; widen de `PostgresTransactionDatabase`. diff --git a/docs/plans/escala-dry-pos-c8.md b/docs/plans/escala-dry-pos-c8.md new file mode 100644 index 00000000..c0eab80d --- /dev/null +++ b/docs/plans/escala-dry-pos-c8.md @@ -0,0 +1,108 @@ +# Escala e DRY pós-C8 (apoiadores / filtros / forms) + +Status: registrado no roadmap (fases pendentes) +Atualizado em: 2026-07-19 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C9) +Responsável: — + +## Contexto + +O C8 ([escala-dry-pos-c6.md](escala-dry-pos-c6.md)) entregou a segunda camada de escala/DRY do cadastro nominal: locks advisory em um round-trip, bulk import com `.returning()` e leituras drizzle na txn, overview sem `COUNT(*)` redundante, migration `pg_trgm` em `contact`, helper `drizzleBulk.ts`, variante `getCoordinatorNucleusIds`, e migração da maioria dos `formActions` para `mapCampaignFormActionError` + `campaignFormFields`. Duas passagens `/simplify` (pré- e pós-rebase em `main` com C7) aplicaram limpezas adicionais (`select: { id: true }` via `DynamicFind`, prefetch de núcleos do coordenador para o overview, chunk de `IN` no import, `drizzleResultRows` no `campaignInviteRepository`, `errorProps` no `CampaignInviteForm`, `safeMessages` opcional no mapper). + +Os revisores (quality / reuse / performance) marcaram como **importantes e maiores que cleanup** os follow-ups abaixo. Sem registro, a lista de apoiadores pode divergir entre Payload e SQL agregado, o coordenador paga 2–3 lookups idênticos por navegação, e o DRY de forms fica incompleto na borda da vertical. + +1. **Filtros de apoiadores duplicados.** `buildSupporterListWhere` (`src/utilities/supporterUi.ts`, Payload `Where`) e `buildAggregateSql` (`src/utilities/supporterListOverviewAggregate.ts`, SQL cru) reimplementam a mesma semântica (telefone normalizado, `q`, `voteIntention`, `city`, `nucleus`). O C8 derivou `total` da lista, mas os dois caminhos ainda podem divergir em qualquer mudança futura. +2. **`contactSearchQuery` não wireado no aggregate.** C7 entregou `isContactSearchQueryReady` / `CONTACT_SEARCH_MIN_LENGTH` em `src/lib/contactSearchQuery.ts` (mín. 2 chars) para comboboxes; o aggregate SQL do overview ainda aplica `ILIKE '%q%'` para `q` de 1 caractere — desperdiça o `pg_trgm` do C8. +3. **Lookup de núcleos do coordenador repetido na página de apoiadores.** `getCoordinatorNucleusIds` roda no prefetch do overview, mas `loadSupporterListPageData` e `loadAccessibleNucleusOptions` disparam access separado (`getAccessibleNucleusIds` → mesma query) sem `req.context` compartilhado em RSC — até 3× por navegação para `coordenador`. +4. **`apoiadores/[id]/formActions.ts` fora do mapper compartilhado.** C8 migrou ~8 `formActions`, mas o detalhe do apoiador mantém ladder inline com contrato de erro diferente (`message` em validação Zod, não `fieldErrors`) — drift e risco de regressão se migrado sem desenho. +5. **Componentes de form com `fieldError` local.** `NucleusForm`, `ActionPlanForm`, `NucleusTerritoryFields`, `VoteEstimateDialog`, `ActionPlanUpdateForm`, etc. ainda usam `errorFor` ou `fieldErrors?.[0]` em vez de `campaignFormFields.ts` — fora do escopo mínimo do C8 Fase 4 (só `NucleusUpdateForm` + `CampaignInviteForm`). + +**Explicitamente fora (revisores pediram skip no simplify):** remover o waterfall lista→overview (tradeoff intencional para `totalDocs` correto); unificar lista+aggregate numa query só (só se latency reclamar); migrar `collectLockParams` do teste de locks; widen de `PostgresTransactionDatabase` (follow-up de plataforma, coberto parcialmente por `assertDrizzleColumns`). + +## Objetivos + +- Um único núcleo de intenção de filtro de apoiadores alimenta Payload (`Where`) e SQL agregado — sem drift entre KPIs e lista. +- Busca `q` no overview (e, se ainda faltar, na lista Payload) respeita `contactSearchQuery` (mín. 2 chars ou dígitos suficientes), alinhado ao C7. +- Página `/campanha/apoiadores` resolve escopo de núcleos do coordenador **uma vez** por render e repassa para list/overview/options. +- Forms restantes da campanha usam `mapCampaignFormActionError` / `fieldError` / `errorProps` sem quebrar contratos de UI existentes. +- Guardrails: sem novo `Consent`; sem migration salvo se unificar filtros exigir índice novo (improvável); `overrideAccess: false` com `user`; locks advisory xact-level inalterados. + +## Decisões travadas + +- **Um item C9, quatro fases ordenadas.** Mesmo racional do C6/C7/C8: um ID de roadmap, PRs por fase. Ordem: filtros (correção) → `contactSearchQuery` + loader da página → forms restantes (detalhe + componentes). +- **Dependência dura de C8 (merge).** O código otimizado é o do C8; não reabre decisões de v1 do C6 (token HMAC, `supporterImportBatch`, `skipContactPhoneInvariant`). +- **Dependência suave de C7** para `contactSearchQuery` — já em `main`; C9 só wireia nos paths de apoiadores que ainda ignoram o guard. +- **Cortável se a base nominal permanecer pequena.** Fases 1–2 só rendem com volume real ou coordenadores ativos; Fases 3–4 (DRY de forms) são baratas e reduzem drift — manter se houver folga. +- **`apoiadores/[id]/formActions`:** migrar só com `resolveBoundaryMessage` / branch pré-mapper que preserve `message` onde a UI espera texto global (não field error). +- **i18n e naming** (AGENTS.md): identificadores em inglês (`buildSupporterListFilterConditions`, `loadSupportersPageData`), strings visíveis em pt-BR. + +## 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. + +## 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"] + F3 --> F4["Fase 4 — fieldError nos componentes restantes"] + + C7["C7 contactSearchQuery ✓"] -.-> F2 +``` + +### 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. + +### 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. + +### 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. + +### 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`). + +**Migration:** nenhuma prevista. Sem Consent novo. + +## Dependências + +- **Dura:** C8 Escala e DRY pós-C6 (merge) — código em `supporterListOverviewAggregate.ts`, `supporterPageData.ts`, `supporterUi.ts`, `campaignFormActionError.ts`. +- **Suave:** C7 `contactSearchQuery` — já em `main`; Fase 2 wireia. +- Reusa: `src/lib/contactSearchQuery.ts`, `getCoordinatorNucleusIds`, `campaignFormFields.ts`, padrão `loadSupporterListPageData`, plano C8 [escala-dry-pos-c6.md](escala-dry-pos-c6.md). + +## Não escopo + +- Reabrir C6/C8 (import bulk, token HMAC, `pg_trgm` migration, locks batch). +- Unificar lista+overview numa única query SQL (só se medição de latency justificar). +- Widen `PostgresTransactionDatabase` — follow-up de plataforma (assertion runtime do C8 cobre drift de colunas no bulk). +- Helpers FormData/URL de território do actionPlan — C7 Fase 3 / [escala-dry-pos-c3.md](escala-dry-pos-c3.md). +- GOTV / dia D — C5. + +## Referências + +- `docs/roadmap.md` — Trilha C, item C9; sequência Janela 2 +- [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/supporterUi.ts` — `buildSupporterListWhere` +- `src/utilities/supporterListOverviewAggregate.ts` — `buildAggregateSql`, `resolveAccessConstraint` +- `src/app/(campaign)/campanha/(app)/apoiadores/page.tsx` — prefetch / waterfall overview +- `src/lib/contactSearchQuery.ts` — `isContactSearchQueryReady` +- `src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts` — ladder legado +- `src/utilities/campaignFormFields.ts` — `fieldError`, `errorProps` +- AGENTS.md — `Contact`, `overrideAccess: false`, naming inglês diff --git a/docs/roadmap.md b/docs/roadmap.md index 0db61315..3e23b30f 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 registrado — escala/DRY pós-C6 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 (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)) 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. @@ -75,6 +75,9 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de - **C2 Cadastro nominal de apoiadores** — 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 estáveis `apoiador-cadastro` / `apoiador-intencao-voto` via `campaignConsent.ts` genérico (`getConsentByKey` / `requireConsentByKey`, falha fechada), actions (create / intenção de voto / import CSV só `geral` / `removeSupporterData`), UI `/campanha/apoiadores` (lista+KPIs, ficha, wizard de import, kit mínimo `wa.me`). Telefone obrigatório no v1; `lideranca` sem acesso à área. Engenharia pronta e mesclada — produção com dados reais espera deploy (build Vercel aplica a migration) + Consent keys + aprovação jurídica (Onda 0). [Plano](plans/cadastro-nominal-apoiadores.md). - **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). ### Ciclo 3 — Trilha E: mapa de projeção por município (registrado 2026-07-19) @@ -104,6 +107,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig | C3 Eventos / agenda de mobilização | `Planos-de-Acao`, `Novo-Plano-de-Acao` | [eventos-agenda-mobilizacao.md](plans/eventos-agenda-mobilizacao.md) | | 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) | | 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) | @@ -147,6 +151,7 @@ flowchart TD 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)"] C4["C4 Demandas"] C5["C5 Operação dia D / GOTV
(proposto, a validar)"] end @@ -191,6 +196,7 @@ flowchart TD C2 --> C5 C6 -.escala da base.-> C5 C6 --> C8 + C8 --> C9 D1 --> D2 JUR -.chave de push.-> D2 A4 --> E2 @@ -203,7 +209,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. 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) 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 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). ### Sequência de execução por janela do calendário @@ -227,31 +233,32 @@ 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) _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7, E1+E3, E2 | -| 13 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela | -| 14 | 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 | +| 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 | **Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.** | Ordem | Item | Plano | Depende de | Paralelizável com | | ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------- | -| 15 | 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 | -| 16 | 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 | -| 17 | 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 | -| 18 | 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 | -| 19 | 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 | -| 20 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, B3, B5, E4, D1 | -| 21 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo | -| 22 | 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 | +| 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 | **Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.** | Ordem | Item | Plano | Depende de | | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| 23 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) | -| 24 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 | -| 25 | 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) | -| 26 | 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. | — | — | +| 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. | — | — | **Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):** @@ -260,7 +267,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), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2, C8 fases 3–4 (DRY de helpers/forms) 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), 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. ### Itens consolidados/removidos nesta revisão (2026-07-17) diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx index 703d6570..d3f673c7 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx @@ -18,7 +18,7 @@ import { EmptyMedia, EmptyTitle, } from '@/components/ui/Empty' -import { isCampaignGeneral } from '@/utilities/campaignAccess' +import { isCampaignGeneral, getCoordinatorNucleusIds } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadAccessibleNucleusOptions, @@ -31,7 +31,6 @@ import { buildSupporterListHref, canAccessSupporterArea, getSupporterScopeLabel, - resolveSupporterListUrl, } from '@/utilities/supporterUi' type SupportersPageProps = { @@ -45,14 +44,24 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro if (!canAccessSupporterArea(user.role)) redirect('/campanha') const rawSearchParams = await searchParams - const provisional = resolveSupporterListUrl(rawSearchParams) - const [{ result, state, redirectHref }, overview, nucleusOptions] = await Promise.all([ - loadSupporterListPageData(payload, user, rawSearchParams), - loadSupporterListOverviewData(payload, user, provisional.state), - loadAccessibleNucleusOptions(payload, user), - ]) + 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( + payload, + user, + state, + result.totalDocs, + coordinatorNucleusIds, + ) + return (
diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts index 0818f528..33cd17d2 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts @@ -3,15 +3,11 @@ import config from '@payload-config' import { getPayload } from 'payload' import { revalidatePath } from 'next/cache' -import { ZodError } from 'zod' import { assignNucleusCoordinators } from '@/app/(campaign)/campanha/actions/coordinatorAssignment' -import { - FormDataBoundaryError, - repeatedRelationshipFormValues, - validationFieldErrors, -} from '@/lib/formData' +import { repeatedRelationshipFormValues } from '@/lib/formData' import { getCampaignUser } from '@/utilities/campaignAuth' +import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions' export type CoordinatorAssignmentFormState = { @@ -25,23 +21,19 @@ export type CoordinatorAssignmentOptionsResult = { options: Array<{ id: number; name: string; isCurrent: boolean }> } -const knownMessages = new Set([ +const knownMessages = [ 'Somente a coordenação geral pode alterar coordenadores.', 'Núcleo não encontrado ou sem acesso.', 'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.', 'Uma ou mais pessoas selecionadas não são mais elegíveis para coordenação.', -]) +] as const -const errorState = (error: unknown): CoordinatorAssignmentFormState => { - if (error instanceof FormDataBoundaryError) { - return { fieldErrors: { [error.field]: [error.message] } } - } - if (error instanceof ZodError) return { fieldErrors: validationFieldErrors(error) } - if (error instanceof Error && knownMessages.has(error.message)) { - return { message: error.message } - } - return { message: 'Não foi possível salvar a coordenação. Atualize a página e tente novamente.' } -} +const errorState = (error: unknown): CoordinatorAssignmentFormState => + mapCampaignFormActionError({ + error, + safeMessages: knownMessages, + genericMessage: 'Não foi possível salvar a coordenação. Atualize a página e tente novamente.', + }) export const assignNucleusCoordinatorsFormAction = async ( slug: string, diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts index 59dd3f89..414d52b2 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts @@ -1,10 +1,9 @@ 'use server' import { revalidatePath } from 'next/cache' -import { ZodError } from 'zod' import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus' -import { FormDataBoundaryError, validationFieldErrors } from '@/lib/formData' +import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' import { parseNucleusIntelligenceFormData } from '@/utilities/nucleusIntelligenceUi' export type NucleusIntelligenceFormState = { @@ -23,12 +22,10 @@ export const updateNucleusIntelligenceFormAction = async ( revalidatePath('/campanha/nucleos/[slug]', 'page') return { status: 'success', message: 'Inteligência do núcleo atualizada.' } } catch (error) { - if (error instanceof FormDataBoundaryError) { - return { fieldErrors: { [error.field]: [error.message] } } - } - if (error instanceof ZodError) return { fieldErrors: validationFieldErrors(error) } - return { - message: 'Não foi possível atualizar a inteligência. Verifique os dados e tente novamente.', - } + return mapCampaignFormActionError({ + error, + genericMessage: + 'Não foi possível atualizar a inteligência. Verifique os dados e tente novamente.', + }) } } diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts index a891445c..3347594e 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts @@ -3,12 +3,11 @@ import config from '@payload-config' import { getPayload, type Payload } from 'payload' import { revalidatePath } from 'next/cache' -import { ZodError } from 'zod' import { createNucleusUpdateRecord } from '@/app/(campaign)/campanha/actions/nucleusUpdate' import type { CampaignUser } from '@/payload-types' -import { FormDataBoundaryError, validationFieldErrors } from '@/lib/formData' import { getCampaignUser } from '@/utilities/campaignAuth' +import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' import { parseNucleusUpdateFormData } from '@/utilities/nucleusUpdateUi' import { requireRelationshipId } from '@/utilities/relationship' @@ -32,15 +31,10 @@ export const createNucleusUpdateFormRecordAction = async ( nucleusId: requireRelationshipId(created.nucleus), } } catch (error) { - if (error instanceof FormDataBoundaryError) { - return { fieldErrors: { [error.field]: [error.message] } } - } - if (error instanceof ZodError) { - return { fieldErrors: validationFieldErrors(error) } - } - return { - message: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.', - } + return mapCampaignFormActionError({ + error, + genericMessage: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.', + }) } } diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts index 87cda2a5..36432c35 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts @@ -1,19 +1,17 @@ 'use server' import { revalidatePath } from 'next/cache' -import { ZodError } from 'zod' import { confirmVoteEstimate, suggestVoteEstimate, } from '@/app/(campaign)/campanha/actions/voteEstimate' import { - FormDataBoundaryError, optionalFormText, requiredIntegerFormValue, requiredRelationshipFormValue, - validationFieldErrors, } from '@/lib/formData' +import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' export type VoteEstimateFormState = { status?: 'success' @@ -21,24 +19,19 @@ export type VoteEstimateFormState = { fieldErrors?: Record } -const errorState = (error: unknown): VoteEstimateFormState => { - if (error instanceof FormDataBoundaryError) { - return { fieldErrors: { [error.field]: [error.message] } } - } - if (error instanceof ZodError) return { fieldErrors: validationFieldErrors(error) } - if ( - error instanceof Error && - [ - 'A liderança precisa de vínculo engajado com este núcleo.', - 'Somente a coordenação pode confirmar estimativas.', - 'A sugestão foi alterada. Atualize a página antes de confirmar.', - 'Informe uma justificativa para ajustar a estimativa.', - ].includes(error.message) - ) { - return { message: error.message } - } - return { message: 'Não foi possível salvar a estimativa. Tente novamente.' } -} +const safeMessages = [ + 'A liderança precisa de vínculo engajado com este núcleo.', + 'Somente a coordenação pode confirmar estimativas.', + 'A sugestão foi alterada. Atualize a página antes de confirmar.', + 'Informe uma justificativa para ajustar a estimativa.', +] as const + +const errorState = (error: unknown): VoteEstimateFormState => + mapCampaignFormActionError({ + error, + safeMessages, + genericMessage: 'Não foi possível salvar a estimativa. Tente novamente.', + }) export const suggestVoteEstimateFormAction = async ( _state: VoteEstimateFormState, diff --git a/src/app/(campaign)/campanha/(app)/nucleos/formActions.ts b/src/app/(campaign)/campanha/(app)/nucleos/formActions.ts index 743dd03b..243732e9 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/nucleos/formActions.ts @@ -3,7 +3,6 @@ import config from '@payload-config' import { getPayload } from 'payload' import { redirect } from 'next/navigation' -import { ZodError } from 'zod' import { archiveNucleus, @@ -11,29 +10,20 @@ import { updateNucleus, } from '@/app/(campaign)/campanha/actions/nucleus' import type { NucleusFormState } from '@/components/campaign/NucleusForm' -import { - FormDataBoundaryError, - optionalFormText, - requiredRelationshipFormValue, - validationFieldErrors, -} from '@/lib/formData' +import { optionalFormText, requiredRelationshipFormValue } from '@/lib/formData' import { getCampaignUser } from '@/utilities/campaignAuth' +import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' import { parseNucleusCreateFormData, parseNucleusUpdateFormData, } from '@/utilities/nucleusFormData' import { slugify } from '@/utilities/slug' -const getActionError = (error: unknown): NucleusFormState => { - if (error instanceof FormDataBoundaryError) { - return { fieldErrors: { [error.field]: [error.message] } } - } - if (error instanceof ZodError) { - return { fieldErrors: validationFieldErrors(error) } - } - - return { message: 'Não foi possível concluir a ação. Atualize a página e tente novamente.' } -} +const getActionError = (error: unknown): NucleusFormState => + mapCampaignFormActionError({ + error, + genericMessage: 'Não foi possível concluir a ação. Atualize a página e tente novamente.', + }) const existingNucleusState = async (formData: FormData): Promise => { const name = optionalFormText(formData, 'name') diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts b/src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts index b45896d4..ace18158 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts @@ -1,15 +1,10 @@ 'use server' import { revalidatePath } from 'next/cache' -import { ZodError } from 'zod' import { appendActionPlanUpdate } from '@/app/(campaign)/campanha/actions/actionPlan' -import { - FormDataBoundaryError, - requiredFormText, - requiredRelationshipFormValue, - validationFieldErrors, -} from '@/lib/formData' +import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData' +import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' export type ActionPlanUpdateFormState = { status?: 'success' @@ -17,6 +12,15 @@ export type ActionPlanUpdateFormState = { fieldErrors?: Record } +// The specific validation/transaction messages `appendActionPlanUpdate` throws — +// safe to surface verbatim. Anything else (e.g. an unexpected Payload API error) +// now falls back to the generic message instead of leaking its raw text. +const safeMessages = [ + 'Informe o texto da atualização.', + 'Atualização muito longa. Reduza o texto e tente novamente.', + 'Não foi possível registrar a atualização do plano.', +] as const + export const createActionPlanUpdateFormAction = async ( _state: ActionPlanUpdateFormState, formData: FormData, @@ -28,17 +32,10 @@ export const createActionPlanUpdateFormAction = async ( revalidatePath('/campanha/planos/[slug]', 'page') return { status: 'success', message: 'Atualização registrada com sucesso.' } } catch (error) { - if (error instanceof FormDataBoundaryError) { - return { fieldErrors: { [error.field]: [error.message] } } - } - if (error instanceof ZodError) { - return { fieldErrors: validationFieldErrors(error) } - } - return { - message: - error instanceof Error - ? error.message - : 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.', - } + return mapCampaignFormActionError({ + error, + safeMessages, + genericMessage: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.', + }) } } diff --git a/src/app/(campaign)/campanha/(app)/planos/formActions.ts b/src/app/(campaign)/campanha/(app)/planos/formActions.ts index 0794eec6..e5e8a3de 100644 --- a/src/app/(campaign)/campanha/(app)/planos/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/planos/formActions.ts @@ -3,16 +3,12 @@ import config from '@payload-config' import { getPayload } from 'payload' import { redirect } from 'next/navigation' -import { ZodError } from 'zod' import { createActionPlan, updateActionPlan } from '@/app/(campaign)/campanha/actions/actionPlan' import type { ActionPlanFormState } from '@/components/campaign/ActionPlanForm' -import { - FormDataBoundaryError, - optionalFormText, - validationFieldErrors, -} from '@/lib/formData' +import { optionalFormText } from '@/lib/formData' import { getCampaignUser } from '@/utilities/campaignAuth' +import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' import { parseActionPlanCreateFormData, parseActionPlanUpdateFormData, @@ -20,17 +16,16 @@ import { import { slugify } from '@/utilities/slug' const getActionError = (error: unknown): ActionPlanFormState => { - if (error instanceof FormDataBoundaryError) { - return { fieldErrors: { [error.field]: [error.message] } } - } - if (error instanceof ZodError) { - return { fieldErrors: validationFieldErrors(error) } - } + // Checked before the shared mapper: a DB-level unique-violation message isn't + // a `FormDataBoundaryError`/`ZodError`/known-safe string, so it needs its own + // branch to surface as a field error instead of falling to the generic message. if (error instanceof Error && /já existe|unique|duplicate key/i.test(error.message)) { return { fieldErrors: { title: ['Já existe um plano com este título.'] } } } - - return { message: 'Não foi possível concluir a ação. Atualize a página e tente novamente.' } + return mapCampaignFormActionError({ + error, + genericMessage: 'Não foi possível concluir a ação. Atualize a página e tente novamente.', + }) } const existingActionPlanState = async (formData: FormData): Promise => { diff --git a/src/app/(campaign)/campanha/convite/[token]/formActions.ts b/src/app/(campaign)/campanha/convite/[token]/formActions.ts index 0b2a974c..9b4523b2 100644 --- a/src/app/(campaign)/campanha/convite/[token]/formActions.ts +++ b/src/app/(campaign)/campanha/convite/[token]/formActions.ts @@ -1,7 +1,6 @@ 'use server' import { redirect } from 'next/navigation' -import { ZodError } from 'zod' import { redeemCampaignInviteAutofill, @@ -10,12 +9,11 @@ import { import { campaignInviteAutofillSchema, campaignInviteLoginSchema } from '@/lib/schemas/invite' import { checkboxFormValue, - FormDataBoundaryError, nullableFormText, requiredFormSecret, requiredFormText, - validationFieldErrors, } from '@/lib/formData' +import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' export type CampaignInviteFormState = { status?: 'success' @@ -37,17 +35,12 @@ const profileFromForm = (formData: FormData) => ({ sectorNotes: nullableFormText(formData, 'sectorNotes'), }) -const inviteFormError = (error: unknown): CampaignInviteFormState => { - if (error instanceof FormDataBoundaryError) { - return { fieldErrors: { [error.field]: [error.message] } } - } - if (error instanceof ZodError) { - return { fieldErrors: validationFieldErrors(error) } - } - return { - message: 'Este convite não está disponível. Peça um novo convite à pessoa que falou com você.', - } -} +const inviteFormError = (error: unknown): CampaignInviteFormState => + mapCampaignFormActionError({ + error, + genericMessage: + 'Este convite não está disponível. Peça um novo convite à pessoa que falou com você.', + }) export const redeemCampaignInviteAutofillFormAction = async ( token: string, diff --git a/src/components/campaign/CampaignInviteForm.tsx b/src/components/campaign/CampaignInviteForm.tsx index 2f640278..2f097eb5 100644 --- a/src/components/campaign/CampaignInviteForm.tsx +++ b/src/components/campaign/CampaignInviteForm.tsx @@ -23,6 +23,7 @@ import { Input } from '@/components/ui/input' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' import { Spinner } from '@/components/ui/Spinner' import { Textarea } from '@/components/ui/textarea' +import { fieldError, errorProps } from '@/utilities/campaignFormFields' import { leadershipGenderLabels, leadershipSectorLabels } from '@/utilities/leadershipUi' type CampaignInviteProfile = { @@ -41,9 +42,6 @@ const sectorOptions = Object.entries(leadershipSectorLabels) as Array< [NonNullable, string] > -const firstError = (state: CampaignInviteFormState, field: string) => - state.fieldErrors?.[field]?.[0] - const CampaignInviteFeedback = ({ state }: { state: CampaignInviteFormState }) => { if (!state.message || state.status === 'success') return null return ( @@ -109,9 +107,17 @@ const ProfileFields = ({ state: CampaignInviteFormState }) => { const { profile } = preview + const inviteField = (field: string) => errorProps(state.fieldErrors, field, 'campaign-invite') + const name = inviteField('name') + const phone = inviteField('phone') + const email = inviteField('email') + const gender = inviteField('gender') + const sector = inviteField('sector') + const sectorNotes = inviteField('sectorNotes') + return ( - + Nome * - {firstError(state, 'name') ? ( - {firstError(state, 'name')} - ) : null} + {name.error ? {name.error} : null} - + Celular (WhatsApp) * - {firstError(state, 'phone') ? ( - {firstError(state, 'phone')} - ) : null} + {phone.error ? {phone.error} : null} - + E-mail (opcional) - {firstError(state, 'email') ? ( - {firstError(state, 'email')} - ) : null} + {email.error ? {email.error} : null}
- + Gênero Não informar {genderOptions.map(([value, label]) => ( @@ -182,22 +180,18 @@ const ProfileFields = ({ ))} - {firstError(state, 'gender') ? ( - {firstError(state, 'gender')} - ) : null} + {gender.error ? {gender.error} : null} - + Setor Não informar {sectorOptions.map(([value, label]) => ( @@ -206,13 +200,11 @@ const ProfileFields = ({ ))} - {firstError(state, 'sector') ? ( - {firstError(state, 'sector')} - ) : null} + {sector.error ? {sector.error} : null}
- + Como você atua nesse setor? (opcional) @@ -221,15 +213,11 @@ const ProfileFields = ({ name="sectorNotes" defaultValue={profile.sectorNotes ?? ''} maxLength={1000} - aria-invalid={Boolean(firstError(state, 'sectorNotes'))} - aria-describedby={ - firstError(state, 'sectorNotes') ? 'campaign-invite-sector-notes-error' : undefined - } + aria-invalid={sectorNotes.invalid} + aria-describedby={sectorNotes.describedBy} /> - {firstError(state, 'sectorNotes') ? ( - - {firstError(state, 'sectorNotes')} - + {sectorNotes.error ? ( + {sectorNotes.error} ) : null}
@@ -250,9 +238,9 @@ export const CampaignInviteForm = ({ requiresConsent: boolean }) => { const [state, formAction, pending] = useActionState(action, {}) - const consentError = firstError(state, 'consentAccepted') - const passwordError = firstError(state, 'password') - const confirmationError = firstError(state, 'passwordConfirmation') + const consentError = fieldError(state.fieldErrors, 'consentAccepted') + const passwordError = fieldError(state.fieldErrors, 'password') + const confirmationError = fieldError(state.fieldErrors, 'passwordConfirmation') if (state.status === 'success') { return ( diff --git a/src/components/campaign/NucleusUpdateForm.tsx b/src/components/campaign/NucleusUpdateForm.tsx index 3cefe382..ade02015 100644 --- a/src/components/campaign/NucleusUpdateForm.tsx +++ b/src/components/campaign/NucleusUpdateForm.tsx @@ -26,6 +26,7 @@ import { Input } from '@/components/ui/input' import { Spinner } from '@/components/ui/Spinner' import { Textarea } from '@/components/ui/textarea' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup' +import { fieldError } from '@/utilities/campaignFormFields' export type NucleusUpdateFormAction = ( state: NucleusUpdateFormState, @@ -38,11 +39,6 @@ const kinds = [ { value: 'nota', label: 'Nota' }, ] as const -const fieldError = ( - fieldErrors: Record | undefined, - field: string, -): string | undefined => fieldErrors?.[field]?.[0] - export const NucleusUpdateForm = ({ nucleusId, action, @@ -59,6 +55,7 @@ export const NucleusUpdateForm = ({ const [kind, setKind] = useState<(typeof kinds)[number]['value']>('semanal') const [state, formAction, pending] = useActionState(action, {}) const isWeekly = kind === 'semanal' + const bodyError = fieldError(state.fieldErrors, 'body') useEffect(() => { if (state.status !== 'success') return @@ -170,7 +167,7 @@ export const NucleusUpdateForm = ({
) : ( - + Texto da atualização *