diff --git a/AGENTS.md b/AGENTS.md index 3a24589a..544b1c07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,6 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-24):** **E9** Fila de alocação — the municipality list became the allocation queue, with **no new route, no new column and no migration** (the column picker is B17 and is not shipped, so every new signal folds into an existing cell). Ordering: two new sort keys in `municipalityUi.ts` — `deficit` (**the new staff default**, descending) and `frescor` — both derived in memory in `applyDerivedMunicipalitySort` over the already-loaded bundle, fixed to the `central` scenario server-side (moving the scenario into the URL is the "Cenário junto aos filtros" fill-in). Freshness: `aggregatePledgesByMunicipality` now selects `declaredAt`/`estimatedAt` and exposes `lastPledgeAt`; `resolveMunicipalityLastSignalAt` (max of `municipality.lastUpdateAt` and `lastPledgeAt`) feeds both the comparator and the view model's `lastSignalAt`, so server ordering and rendered copy cannot drift; cold threshold `MUNICIPALITY_COLD_SIGNAL_DAYS` = 21. UI: "há N dias" in the existing "Última atualização" column (amber `text-estimate-pending-foreground`, deliberately not `destructive` — the priority and "sem responsável" badges already compete for red on the same row), a "sem responsável" badge when `priority === 'alta'` with zero advisors, the "coluna da vergonha" as the advisor-coverage metric's detail linking `?priority=alta&coverage=sem_assessor`, a scenario-named coverage tooltip, and the leftover "Praças" copy fixed to "Municípios". Cut and re-homed: votes at stake → **B13**, LQ/capture → **E10**, a dedicated deficit column → unnecessary (the E8 cell already shows % and signed deficit). Plano: [`docs/plans/fila-de-alocacao.md`](docs/plans/fila-de-alocacao.md). - **Recently resolved (2026-07-24):** **E18** Documentação dos conceitos de inteligência — staff-only route `/campanha/conceitos` (`isCampaignStaff`; `leader` redirected to `/campanha`) rendering `src/lib/campaignIntelligenceConcepts.ts`. The glossary is **curated content in code, not a Payload collection**: each entry (what it measures, the formula in plain text, a hand-written example, why it matters, where it appears) sits next to the formulas it documents (`municipalityPotential.ts` / `goalCoverage.ts`) and is reviewed in the same PR that changes them; a unit test guards anchor-safe unique IDs and content completeness. Seven E8 concepts in three groups — base do cálculo (válidos projetados, teto do campo), diagnóstico (captura, share intracampo, roll-off), meta e cobertura (meta/meta sugerida, cobertura da meta). Reading surface, not cards: one `article` per concept with hairline dividers, `max-w-prose` column, sticky index at `lg` only, `article:target` tint so anchor arrivals land oriented. Two discovery paths from `MunicipalityGoalAccountCard`: per-metric "Saiba mais" links inside the hover tooltips (`MetricExplanation` takes a `conceptID`) and a whole-page link in the title `CampaignInfoHint` **Popover** — the Popover is the keyboard path because Radix Tooltip content is not tabbable; for touch, `MunicipalityHoverTooltip` now treats its own content as "inside" in the `pointerdown` dismiss handler (the link used to unmount before the tap landed). Sidebar entry lives in `staffSecondaryNav` (`nav.ts`) rendered in its own `SidebarGroup` with `mt-auto` at the foot of the navigation — deliberately outside `staffNav` (the seven work destinations) and outside the mobile bottom bar, since it is reference material; `getCampaignSecondaryNav` returns nothing for `leader`. Fixed an E8 defect found while writing the docs: `formatElectionNumber` rendered a fractional suggested goal as "100,968" (read as a hundred thousand in pt-BR) — now `maximumFractionDigits: 0`. No migration, no collection, no server action; later slices of the intelligence program (E9/E10/B13/E11/E12/E13/E14) each append their section to the array. Plano: [`docs/plans/documentacao-conceitos-campanha.md`](docs/plans/documentacao-conceitos-campanha.md). **Recently resolved (2026-07-24):** **B19** Gerenciar assessores — `/campanha/assessores` (lista + novo + detalhe) for coordinator/candidate only (`isCampaignUnrestricted`); create/edit `advisor` accounts, municipality portfolio (per-municipality auto-save + advisory lock), password-reset email (blocks `@planilha.invalid`); aligned `canUpdateCampaignUser` / phone field access / `canAssignMunicipalityAdvisors` / `assignMunicipalityAdvisorsRecord` to unrestricted; privileged email read in `advisorData` loaders after the route gate; `reloadUnrestrictedActor`. No migration. Plano: [`docs/plans/gerenciar-assessores.md`](docs/plans/gerenciar-assessores.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8145eef1..3ede529b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,11 +6,11 @@ O mapa conceitual do repositório: como o sistema se divide, em que direção o Um único app Next.js (App Router) + Payload CMS sobre um Postgres (Neon em produção, Docker local), hospedado na Vercel. Três produtos em três route groups, com **duas barreiras de autenticação independentes** que podem coexistir no mesmo navegador: -| Route group | Produto | Auth | -| -------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `(frontend)` | Site público (notícias, petições, WhatsApp) | anônimo; ISR + tag caching | -| `(payload)` | Admin editorial `/admin` | collection `users`, cookie `payload-token` | -| `(campaign)` | Ferramenta interna `/campanha` | collection `campaignUser`, cookie próprio `campaign-token` (path `/campanha`); barreira no layout de `(app)` | +| Route group | Produto | Auth | +| ------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `(frontend)` | Site público (notícias, petições, WhatsApp) | anônimo; ISR + tag caching | +| `(payload)` | Admin editorial `/admin` | collection `users`, cookie `payload-token` | +| `(campaign)` | Ferramenta interna `/campanha` | collection `campaignUser`, cookie próprio `campaign-token` (path `/campanha`); barreira no layout de `(app)` | Donations ficam FORA do app (QueroApoiar). Dados eleitorais TSE são importados por seeds locais e servidos por artefato commitado + collections cacheadas — o build da Vercel **nunca** depende de conteúdo do banco além de rodar `payload migrate`. @@ -61,11 +61,11 @@ flowchart LR Papéis em `campaignUser.role` — predicados client-safe em `lib/campaignRoles.ts`, checks de ator em `utilities/access/shared.ts`, regras por domínio em `utilities/access/*` (re-exportadas por `campaignAccess.ts`): -| Papel | Escopo | -| ------------- | ---------------------------------------------------------------------------------------------------------- | -| `coordinator` | tudo (staff irrestrito); único que importa CSV de apoiadores e gerencia assessores (com `candidate`) | -| `candidate` | visibilidade irrestrita (staff); elegível como assessor responsável | -| `advisor` | staff restrito aos municípios em `municipality.advisors` | +| Papel | Escopo | +| ------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `coordinator` | tudo (staff irrestrito); único que importa CSV de apoiadores e gerencia assessores (com `candidate`) | +| `candidate` | visibilidade irrestrita (staff); elegível como assessor responsável | +| `advisor` | staff restrito aos municípios em `municipality.advisors` | | `leader` | **lockdown**: home = ferramenta de contatos; lê só apoiadores que criou; sem municípios/pledges/demandas/planos/eleições | Assimetria estrutural de pledge: staff registra `declaredVotes` + `estimatedVotes` (3 cenários); a liderança **nunca** vê estimativas (view models por papel; agregados usam `estimated[S] ?? declared`, default `central`). @@ -79,7 +79,7 @@ Dois contexts principais + um de suporte, espelhados nos prefixos de módulo e n - **Município** — a unidade operacional PREDEFINIDA (435 no catálogo: 416 municípios; Salvador em 19 zonas; Camaçari inteiro). ≠ município IBGE genérico; geografia é read-only, estratégia é editável. Sinônimos históricos mortos: Núcleo, Praça (copy varrida no Pass 2 W4f; só migrations congeladas e textos provisórios de consent do Onda 0 ainda dizem "Praça"). - **Liderança** — ficha única por pessoa (`contact` UNIQUE), atua em N municípios/organizações; pode ter conta `leader`. - **Pledge (compromisso de votos)** — por liderança×município; `declaredVotes` (staff-entered, visível à liderança) vs `estimatedVotes` (staff-only, 3 cenários: `pessimistic|central|optimistic`). -- **Conta da cadeira (E8)** — vocabulário staff-only: *válidos projetados*, *teto do campo*, *captura*, *share intracampo*, *roll-off*, *meta* (`expectedVotes[cenário] ?? meta sugerida`), *cobertura da meta* (`comprometido ÷ meta`, comprometido = SÓ pledges). Glossário do usuário em `/campanha/conceitos` (`lib/campaignIntelligenceConcepts.ts`). +- **Conta da cadeira (E8)** — vocabulário staff-only: _válidos projetados_, _teto do campo_, _captura_, _share intracampo_, _roll-off_, _meta_ (`expectedVotes[cenário] ?? meta sugerida`), _cobertura da meta_ (`comprometido ÷ meta`, comprometido = SÓ pledges). Glossário do usuário em `/campanha/conceitos` (`lib/campaignIntelligenceConcepts.ts`). - **Demanda** — pedido de material/serviço, staff-only, escalada decidida por coordinator/candidate. **Plano de Ação** — evento/agenda ancorado em UM município. **Apoiador** — join `Contact`↔campanha (consent LGPD fail-closed por chave estável). **Dobradinha** — deputado estadual parceiro (`stateDeputy`). **Assessor responsável** — carteira em `municipality.advisors`. - **Contact** é o registro normalizado de PESSOA compartilhado com o site público — features novas criam JOINS para `contact`, nunca uma segunda tabela de pessoa. @@ -89,17 +89,17 @@ Dois contexts principais + um de suporte, espelhados nos prefixos de módulo e n ## Log de decisões estruturais -| Data | Decisão | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 2026-07-16 | Auth da campanha isolada do admin: collection `campaignUser` + cookie `campaign-token` com path próprio | -| 2026-07-23 | Toda collection declara `access` explícito; default do Payload tratado como bug (JWT de campanha alcança `/api/*`) | -| 2026-07-23 | Município como unidade operacional predefinida (remodel destrutivo aplicado em produção) | -| 2026-07-23 | Agregados TSE imutáveis viram artefato commitado; build da Vercel não depende de conteúdo do banco | +| Data | Decisão | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-07-16 | Auth da campanha isolada do admin: collection `campaignUser` + cookie `campaign-token` com path próprio | +| 2026-07-23 | Toda collection declara `access` explícito; default do Payload tratado como bug (JWT de campanha alcança `/api/*`) | +| 2026-07-23 | Município como unidade operacional predefinida (remodel destrutivo aplicado em produção) | +| 2026-07-23 | Agregados TSE imutáveis viram artefato commitado; build da Vercel não depende de conteúdo do banco | | 2026-07-25 | **Pass 2 D1:** NO-GO em `src/domains/` e ports-and-adapters; GO em convenções + correções de fronteira + subpastas por domínio DENTRO de `components/campaign/`; subpastas em `utilities/` adiadas (gatilho: 3º módulo novo de um domínio num mês) — [decisao-arquitetura-dominios.md](plans/decisao-arquitetura-dominios.md) | -| 2026-07-25 | **Pass 2 D2:** matemática de insights da era núcleos DELETADA (`electionInsights.ts`); E10 nasce em módulo novo com classificação relativa | -| 2026-07-25 | **Pass 2 W1/D5:** listas de tabela no sistema compartilhado (colunas como dado); exceções documentadas: planos (cards), `TerritoryOverviewTable` (sort client, ≤27 linhas), comparação de candidatos, LeaderContacts, preview de import | -| 2026-07-25 | **Pass 2 W2:** dedup por POLÍTICA continua valendo (wrappers nomeados como `runCampaignFormAction`), nunca plumbing genérico de dados; aliases de access `canUpdateX = canReadX` são declarações de política deliberadas | -| 2026-07-25 | **Pass 2 W4b:** knip `exports`/`types`/`enumMembers` em ERROR no CI; tests fazem parte do grafo; kit `ui/` sem peças mortas (shadcn re-adiciona sob demanda) | +| 2026-07-25 | **Pass 2 D2:** matemática de insights da era núcleos DELETADA (`electionInsights.ts`); E10 nasce em módulo novo com classificação relativa | +| 2026-07-25 | **Pass 2 W1/D5:** listas de tabela no sistema compartilhado (colunas como dado); exceções documentadas: planos (cards), `TerritoryOverviewTable` (sort client, ≤27 linhas), comparação de candidatos, LeaderContacts, preview de import | +| 2026-07-25 | **Pass 2 W2:** dedup por POLÍTICA continua valendo (wrappers nomeados como `runCampaignFormAction`), nunca plumbing genérico de dados; aliases de access `canUpdateX = canReadX` são declarações de política deliberadas | +| 2026-07-25 | **Pass 2 W4b:** knip `exports`/`types`/`enumMembers` em ERROR no CI; tests fazem parte do grafo; kit `ui/` sem peças mortas (shadcn re-adiciona sob demanda) | ## Manutenção deste documento diff --git a/docs/IMPROVE-CODE-QUALITY-PLAN.md b/docs/IMPROVE-CODE-QUALITY-PLAN.md index 1be68433..9bf0a0a5 100644 --- a/docs/IMPROVE-CODE-QUALITY-PLAN.md +++ b/docs/IMPROVE-CODE-QUALITY-PLAN.md @@ -66,33 +66,33 @@ Statuses: pending · in-progress · awaiting-evidence · done · deferred: \ · skipped: \ ## Pass 2 Decisions (signed off 2026-07-25) -| ID | Decision | Rationale | -| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| D1 | **No `src/domains/` reorg, no ports-and-adapters.** Conventions + mechanical boundary fixes + staged within-layer subfolders for `components/campaign/`; `utilities/` subfoldering deferred with trigger | Payload collections ARE the data model; a big-bang move freezes the repo mid-window and risks the importmap. Full evaluation in [decisao-arquitetura-dominios.md](plans/decisao-arquitetura-dominios.md) | -| D2 | **Delete** the ~52 consumer-less `electionInsights` exports (+ their tests) rather than quarantining them | git history + plans carry the formulas; E10 replaces the 35/20/10 threshold approach with relative classification — keeping dead math invites accidental reuse | -| D3 | `consent: 2` hardcode folded into the Onda 0 provisional-key pattern in-pass, as an isolated delivery with the migration SQL explicitly reviewed by the user before landing | Same fail-closed pattern as `lideranca-autopreenchimento`; leaving the hardcode makes the legal batch harder to verify | -| D4 | users-roles migration stays ledgered/deferred | Schema migration; only blocks widening `/admin`, which isn't scheduled | -| D5 | W1 scope boundary: planos stays cards (adopts toolbar/URL state only), `TerritoryOverviewTable` stays a documented client-sort exception, LeaderContacts/candidate-comparison/import-preview stay out | Anti-classitis: the system earns its migrations; small static tables don't pay for the abstraction | -| D6 | Praça sweep = user-visible copy + admin labels; provisional Onda 0 consent texts untouched (legal batch) | Consent text versions are counsel-owned; code shouldn't edit them cosmetically | +| ID | Decision | Rationale | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D1 | **No `src/domains/` reorg, no ports-and-adapters.** Conventions + mechanical boundary fixes + staged within-layer subfolders for `components/campaign/`; `utilities/` subfoldering deferred with trigger | Payload collections ARE the data model; a big-bang move freezes the repo mid-window and risks the importmap. Full evaluation in [decisao-arquitetura-dominios.md](plans/decisao-arquitetura-dominios.md) | +| D2 | **Delete** the ~52 consumer-less `electionInsights` exports (+ their tests) rather than quarantining them | git history + plans carry the formulas; E10 replaces the 35/20/10 threshold approach with relative classification — keeping dead math invites accidental reuse | +| D3 | `consent: 2` hardcode folded into the Onda 0 provisional-key pattern in-pass, as an isolated delivery with the migration SQL explicitly reviewed by the user before landing | Same fail-closed pattern as `lideranca-autopreenchimento`; leaving the hardcode makes the legal batch harder to verify | +| D4 | users-roles migration stays ledgered/deferred | Schema migration; only blocks widening `/admin`, which isn't scheduled | +| D5 | W1 scope boundary: planos stays cards (adopts toolbar/URL state only), `TerritoryOverviewTable` stays a documented client-sort exception, LeaderContacts/candidate-comparison/import-preview stay out | Anti-classitis: the system earns its migrations; small static tables don't pay for the abstraction | +| D6 | Praça sweep = user-visible copy + admin labels; provisional Onda 0 consent texts untouched (legal batch) | Consent text versions are counsel-owned; code shouldn't edit them cosmetically | ## Next Actions diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md index 1417f377..6641bb38 100644 --- a/docs/TECH-DEBT.md +++ b/docs/TECH-DEBT.md @@ -4,40 +4,40 @@ Ledger of known engineering debt. Items large enough to be scheduled work become ## Debt Ledger -| Item | Location | Type | Risk | Effort | Priority | Status | Ref | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ----------------------------------------------------------------------------------------- | -------------------- | +| Item | Location | Type | Risk | Effort | Priority | Status | Ref | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | | 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b | -| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | | -| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | | -| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | open | AGENTS Known Gaps #1 | -| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | | -| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | | -| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 | -| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | | -| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | | -| E2E campaign journeys latency-flake on loaded local machines (5s expect / 30s test budgets vs cold dev compiles; suite green with CI retries) | `tests/e2e/campaignMunicipalities.e2e.spec.ts` | test-infra | low | S | P3 | open — consider per-step budgets or a warmed dist dir | | -| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | | -| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | | -| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | | -| Map bundle serializes all years × scenarios (~100KB raw) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~15-25KB on the wire; instant year/scenario switching is a core analysis flow worth the bytes | M | P3 | deferred 2026-07-23 with rationale (revisit only if RSC payload profiling says otherwise) | | -| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | | -| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f | +| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | | +| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | | +| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | open | AGENTS Known Gaps #1 | +| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | | +| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | | +| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 | +| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | | +| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | | +| E2E campaign journeys latency-flake on loaded local machines (5s expect / 30s test budgets vs cold dev compiles; suite green with CI retries) | `tests/e2e/campaignMunicipalities.e2e.spec.ts` | test-infra | low | S | P3 | open — consider per-step budgets or a warmed dist dir | | +| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | | +| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | | +| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | | +| Map bundle serializes all years × scenarios (~100KB raw) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~15-25KB on the wire; instant year/scenario switching is a core analysis flow worth the bytes | M | P3 | deferred 2026-07-23 with rationale (revisit only if RSC payload profiling says otherwise) | | +| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | | +| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f | ## Smell Inventory -| Smell | Location | Refactoring | Status | -| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| God module: RBAC for every collection in one file (964 lines, ~80 exports) | `src/utilities/campaignAccess.ts` | split per domain under `src/utilities/access/` | done 2026-07-23 (14 domain modules; `campaignAccess.ts` kept as re-export surface) | -| God module: ~71 exports of electoral math | `src/lib/electionInsights.ts` | split by concern when next touched | done 2026-07-25 (Pass 2 W4a): module DELETED; survivors in `electionFormat.ts` + `voteTrend.ts` | -| Multi-feature action bag: CRUD + CSV import pipeline + LGPD in one file (722 lines) | `src/app/(campaign)/campanha/actions/supporter.ts` | split CRUD vs import | done 2026-07-23 (`supporterImport.ts`) | -| Clone twins: create/update/staff-assert/unique-conflict skeleton | `actions/organization.ts` ↔ `actions/stateDeputy.ts` | extract shared factory | done 2026-07-23 (`runStaffEntityMutation` in `campaignEntityActions.ts`) | -| 14 near-identical `formActions.ts` try/catch→mapError→redirect/return ladders | `src/app/(campaign)/campanha/**/formActions.ts` | shared form-action wrapper | partially done — C8 F4 (2026-07-21/23) unified the municipality staff wrappers + 4 `*/nova` ladders on `runCampaignRedirectFormAction`; **10 ladders written since remain hand-rolled** (844 lines: assessores, planos, apoiadores ×2, demandas/[slug], liderancas/[id], organizacoes/[slug], dobradinhas/[slug], municipios editar, convite) — done 2026-07-25 (Pass 2 W4d): `runCampaignFormAction` + 13 ações migradas; exceções documentadas em comentário (planos, apoiadores/[id], convite login) | -| Flat 80+-file `src/utilities/` junk drawer; `lib/` vs `utilities/` boundary undocumented | `src/utilities/`, `src/lib/` | document convention + move misfiled modules | done 2026-07-23 (convention in `.cursor/rules/engineering-standards.mdc`; `RefreshRouteOnSave` moved to `components/`) | -| Praça→Município rename leftovers in identifiers (`PLAZA_MAP_YEARS`, `ACCESSIBLE_PLAZA_IDS_CONTEXT_KEY`, …) | map/list utilities | finish rename | done 2026-07-23 | -| Props→state `useEffect` mirrors | `MunicipalityListExpectedVotesControl`, comboboxes | key-remount or fully controlled | closed 2026-07-23 (accepted as correct — see Debt Ledger row) | -| Detail-page RSCs mixing loading + tab UI + forms (300+ lines) | `municipios/[slug]/page.tsx`, `planos/[slug]/page.tsx` | extract tab loaders/sections | done 2026-07-25 (Pass 2 W4c): tabs em `MunicipalityDetailTabs.tsx` / `ActionPlanOverviewTab.tsx`; gêmeos de tab dedupados em `detailTabUi.ts` | -| God module: list URL + filters + sort + labels + freshness in one file (765 lines, 59 exports) | `src/utilities/municipalityUi.ts` | split along url/filters/sort/labels/freshness seams | done 2026-07-25 (Pass 2 W1-D1): `municipalityListUrl` / `municipalityListFilters` / `municipalityLabels` / `municipalitySignal` | -| `components/campaign/` flat with 106 files; `utilities/` flat with 92 | `src/components/campaign/`, `src/utilities/` | within-layer domain subfolders | components done 2026-07-25 (Pass 2 W2, 15 domínios); `utilities/` deferred with trigger: a domain gaining its 3rd new module in a month gets its subfolder | +| Smell | Location | Refactoring | Status | +| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| God module: RBAC for every collection in one file (964 lines, ~80 exports) | `src/utilities/campaignAccess.ts` | split per domain under `src/utilities/access/` | done 2026-07-23 (14 domain modules; `campaignAccess.ts` kept as re-export surface) | +| God module: ~71 exports of electoral math | `src/lib/electionInsights.ts` | split by concern when next touched | done 2026-07-25 (Pass 2 W4a): module DELETED; survivors in `electionFormat.ts` + `voteTrend.ts` | +| Multi-feature action bag: CRUD + CSV import pipeline + LGPD in one file (722 lines) | `src/app/(campaign)/campanha/actions/supporter.ts` | split CRUD vs import | done 2026-07-23 (`supporterImport.ts`) | +| Clone twins: create/update/staff-assert/unique-conflict skeleton | `actions/organization.ts` ↔ `actions/stateDeputy.ts` | extract shared factory | done 2026-07-23 (`runStaffEntityMutation` in `campaignEntityActions.ts`) | +| 14 near-identical `formActions.ts` try/catch→mapError→redirect/return ladders | `src/app/(campaign)/campanha/**/formActions.ts` | shared form-action wrapper | partially done — C8 F4 (2026-07-21/23) unified the municipality staff wrappers + 4 `*/nova` ladders on `runCampaignRedirectFormAction`; **10 ladders written since remain hand-rolled** (844 lines: assessores, planos, apoiadores ×2, demandas/[slug], liderancas/[id], organizacoes/[slug], dobradinhas/[slug], municipios editar, convite) — done 2026-07-25 (Pass 2 W4d): `runCampaignFormAction` + 13 ações migradas; exceções documentadas em comentário (planos, apoiadores/[id], convite login) | +| Flat 80+-file `src/utilities/` junk drawer; `lib/` vs `utilities/` boundary undocumented | `src/utilities/`, `src/lib/` | document convention + move misfiled modules | done 2026-07-23 (convention in `.cursor/rules/engineering-standards.mdc`; `RefreshRouteOnSave` moved to `components/`) | +| Praça→Município rename leftovers in identifiers (`PLAZA_MAP_YEARS`, `ACCESSIBLE_PLAZA_IDS_CONTEXT_KEY`, …) | map/list utilities | finish rename | done 2026-07-23 | +| Props→state `useEffect` mirrors | `MunicipalityListExpectedVotesControl`, comboboxes | key-remount or fully controlled | closed 2026-07-23 (accepted as correct — see Debt Ledger row) | +| Detail-page RSCs mixing loading + tab UI + forms (300+ lines) | `municipios/[slug]/page.tsx`, `planos/[slug]/page.tsx` | extract tab loaders/sections | done 2026-07-25 (Pass 2 W4c): tabs em `MunicipalityDetailTabs.tsx` / `ActionPlanOverviewTab.tsx`; gêmeos de tab dedupados em `detailTabUi.ts` | +| God module: list URL + filters + sort + labels + freshness in one file (765 lines, 59 exports) | `src/utilities/municipalityUi.ts` | split along url/filters/sort/labels/freshness seams | done 2026-07-25 (Pass 2 W1-D1): `municipalityListUrl` / `municipalityListFilters` / `municipalityLabels` / `municipalitySignal` | +| `components/campaign/` flat with 106 files; `utilities/` flat with 92 | `src/components/campaign/`, `src/utilities/` | within-layer domain subfolders | components done 2026-07-25 (Pass 2 W2, 15 domínios); `utilities/` deferred with trigger: a domain gaining its 3rd new module in a month gets its subfolder | ## Sprout / Wrap Register diff --git a/knip.json b/knip.json index 4d647cf9..4413b266 100644 --- a/knip.json +++ b/knip.json @@ -9,16 +9,10 @@ "tests/**/*.spec.ts", "tests/helpers/serverOnly.ts" ], - "project": [ - "src/**/*.{ts,tsx,js}", - "scripts/**/*.mjs", - "tests/**/*.ts" - ], + "project": ["src/**/*.{ts,tsx,js}", "scripts/**/*.mjs", "tests/**/*.ts"], "ignore": [], "paths": { - "server-only": [ - "tests/helpers/serverOnly.ts" - ] + "server-only": ["tests/helpers/serverOnly.ts"] }, "ignoreBinaries": [], "ignoreDependencies": [ @@ -34,4 +28,4 @@ "enumMembers": "error", "duplicates": "off" } -} \ No newline at end of file +} diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts index 44258639..31c30b90 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts @@ -11,12 +11,9 @@ import { removeSupporterData, setSupporterVoteIntention, } from '@/app/(campaign)/campanha/actions/supporter' -import { - checkboxFormValue, - requiredRelationshipFormValue, -} from '@/lib/formData' -import { supporterRemoveSchema, supporterVoteIntentionSchema } from '@/lib/schemas/supporter' +import { checkboxFormValue, requiredRelationshipFormValue } from '@/lib/formData' import type { SupporterVoteIntention } from '@/lib/schemas/supporter' +import { supporterRemoveSchema, supporterVoteIntentionSchema } from '@/lib/schemas/supporter' import { mapCampaignFormActionError, type CampaignFormErrorState, @@ -38,9 +35,7 @@ const safeVoteIntentionMessages = [ 'Somente a coordenação e a assessoria podem gerenciar apoiadores.', ] as const -const toMessageOnlyState = ( - mapped: CampaignFormErrorState, -): { message?: string } => { +const toMessageOnlyState = (mapped: CampaignFormErrorState): { message?: string } => { if (mapped.message) return { message: mapped.message } if (!mapped.fieldErrors) return {} diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts index 5716dfd4..0f5a6365 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts @@ -3,14 +3,10 @@ import { revalidatePath } from 'next/cache' import { createSupporter } from '@/app/(campaign)/campanha/actions/supporter' -import { - checkboxFormValue, - nullableRelationshipFormValue, - optionalFormText, -} from '@/lib/formData' +import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData' +import { sanitizeBrazilianPhoneInput } from '@/lib/phone' import { supporterCreateSchema } from '@/lib/schemas/supporter' import { runCampaignFormAction } from '@/utilities/campaignFormActionError' -import { sanitizeBrazilianPhoneInput } from '@/lib/phone' export type SupporterFormState = { status?: 'success' diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx index 39f4e392..a7ce16c5 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx @@ -10,8 +10,8 @@ import { getPayload } from 'payload' import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' -import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge' +import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { SupporterFilters } from '@/components/campaign/supporter/SupporterFilters' import { SupporterList } from '@/components/campaign/supporter/SupporterList' import { SupporterListOverview } from '@/components/campaign/supporter/SupporterListOverview' diff --git a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts index 34cbf9a0..7f1008ba 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts @@ -7,11 +7,7 @@ import { setAdvisorMunicipalityMembership, updateAdvisorProfile, } from '@/app/(campaign)/campanha/actions/advisor' -import { - nullableFormText, - requiredFormText, - requiredRelationshipFormValue, -} from '@/lib/formData' +import { nullableFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData' import { ADVISOR_ACTION_SAFE_MESSAGES } from '@/lib/schemas/advisor' import { runCampaignFormAction, diff --git a/src/app/(campaign)/campanha/(app)/demandas/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/page.tsx index e6442168..58779dac 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/page.tsx @@ -11,12 +11,12 @@ import { CampaignListPendingBoundary, CampaignListResults, } from '@/components/campaign/shared/CampaignListPending' -import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { CampaignTable, CampaignTableHead, type CampaignTableColumn, } from '@/components/campaign/shared/CampaignTable' +import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts index 6194fa99..af2bdd75 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts @@ -24,6 +24,5 @@ export const updateStateDeputyFormAction = async ( return { message: 'Dobradinha atualizada.' } }, safeMessages: ['Somente a coordenação e a assessoria gerenciam dobradinhas.'], - genericMessage: - 'Não foi possível salvar a dobradinha. Verifique seu acesso e tente novamente.', + genericMessage: 'Não foi possível salvar a dobradinha. Verifique seu acesso e tente novamente.', }) diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts index ec4a942c..699fe495 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts @@ -39,6 +39,5 @@ export const updateMunicipalityStrategyFormAction = async ( return { message: 'Estratégia atualizada.' } }, safeMessages: municipalityStaffEditSafeMessages, - genericMessage: - 'Não foi possível salvar a estratégia. Verifique seu acesso e tente novamente.', + genericMessage: 'Não foi possível salvar a estratégia. Verifique seu acesso e tente novamente.', }) diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx index e91ab2d9..366b53ea 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx @@ -17,17 +17,17 @@ import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { actionPlanKindLabels } from '@/lib/schemas/actionPlan' -import { isCampaignStaff } from '@/utilities/campaignAccess' -import { getCampaignUser } from '@/utilities/campaignAuth' -import { - ActionPlanNotFoundError, - resolveAccessibleActionPlanContext, -} from '@/utilities/actionPlanPageData' import { getActionPlanDetailPageData } from '@/utilities/actionPlanDetailPageData' import { getActionPlanDetailTabRedirect, resolveActionPlanDetailTab, } from '@/utilities/actionPlanDetailTabUi' +import { + ActionPlanNotFoundError, + resolveAccessibleActionPlanContext, +} from '@/utilities/actionPlanPageData' +import { isCampaignStaff } from '@/utilities/campaignAccess' +import { getCampaignUser } from '@/utilities/campaignAuth' import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' import { ActionPlanOverviewTab } from './ActionPlanOverviewTab' diff --git a/src/app/(campaign)/campanha/(app)/planos/formActions.ts b/src/app/(campaign)/campanha/(app)/planos/formActions.ts index d7a67032..6231d4a4 100644 --- a/src/app/(campaign)/campanha/(app)/planos/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/planos/formActions.ts @@ -7,19 +7,19 @@ // don't grow parameters for. import config from '@payload-config' -import { getPayload } from 'payload' import { redirect } from 'next/navigation' +import { getPayload } from 'payload' import { createActionPlan, updateActionPlan } from '@/app/(campaign)/campanha/actions/actionPlan' import type { ActionPlanFormState } from '@/components/campaign/actionPlan/ActionPlanForm' import { optionalFormText } from '@/lib/formData' -import { getCampaignUser } from '@/utilities/campaignAuth' -import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' +import { slugify } from '@/lib/slug' import { parseActionPlanCreateFormData, parseActionPlanUpdateFormData, } from '@/utilities/actionPlanFormData' -import { slugify } from '@/lib/slug' +import { getCampaignUser } from '@/utilities/campaignAuth' +import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' const getActionError = (error: unknown): ActionPlanFormState => { if ( diff --git a/src/app/(campaign)/campanha/actions/demand.ts b/src/app/(campaign)/campanha/actions/demand.ts index 25177f7f..507c15d7 100644 --- a/src/app/(campaign)/campanha/actions/demand.ts +++ b/src/app/(campaign)/campanha/actions/demand.ts @@ -17,9 +17,9 @@ import { reloadCampaignActor, reloadStaffActor, } from '@/utilities/campaignActionContext' +import { hookFilledCreateData } from '@/utilities/hookFilledData' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' -import { hookFilledCreateData } from '@/utilities/hookFilledData' export const createCampaignDemandRecord = async ( payload: Payload, diff --git a/src/app/(campaign)/campanha/convite/[token]/formActions.ts b/src/app/(campaign)/campanha/convite/[token]/formActions.ts index 2d3d3db7..13bd6e43 100644 --- a/src/app/(campaign)/campanha/convite/[token]/formActions.ts +++ b/src/app/(campaign)/campanha/convite/[token]/formActions.ts @@ -6,13 +6,13 @@ import { redeemCampaignInviteAutofill, redeemCampaignInviteLogin, } from '@/app/(campaign)/campanha/actions/invite' -import { campaignInviteAutofillSchema, campaignInviteLoginSchema } from '@/lib/schemas/invite' import { checkboxFormValue, nullableFormText, requiredFormSecret, requiredFormText, } from '@/lib/formData' +import { campaignInviteAutofillSchema, campaignInviteLoginSchema } from '@/lib/schemas/invite' import { mapCampaignFormActionError, runCampaignFormAction, diff --git a/src/migrations/20260725_170000_whatsapp_subscription_consent_key.ts b/src/migrations/20260725_170000_whatsapp_subscription_consent_key.ts index 05360e3e..35ab2ab9 100644 --- a/src/migrations/20260725_170000_whatsapp_subscription_consent_key.ts +++ b/src/migrations/20260725_170000_whatsapp_subscription_consent_key.ts @@ -1,4 +1,4 @@ -import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' +import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres' /** * Pass 2 D3 — retire the last hardcoded consent document id. diff --git a/src/utilities/supporterUi.ts b/src/utilities/supporterUi.ts index aefaaf93..3cfedae2 100644 --- a/src/utilities/supporterUi.ts +++ b/src/utilities/supporterUi.ts @@ -1,11 +1,11 @@ import type { Where } from 'payload' +import { isStaffCampaignRole } from '@/lib/campaignRoles' import { isSupporterVoteIntention, resolveBahiaMunicipality, type SupporterVoteIntention, } from '@/lib/schemas/supporter' -import { isStaffCampaignRole } from '@/lib/campaignRoles' import type { CampaignUser, Supporter } from '@/payload-types' import { buildListHref, @@ -44,13 +44,7 @@ export type SupporterListState = { type RawSearchParams = CampaignListRawSearchParams -const supporterListParamNames = [ - 'q', - 'voteIntention', - 'city', - 'municipality', - 'page', -] as const +const supporterListParamNames = ['q', 'voteIntention', 'city', 'municipality', 'page'] as const const supporterListParamNameSet = new Set(supporterListParamNames)