diff --git a/.cursor/cloud-setup.sh b/.cursor/cloud-setup.sh new file mode 100755 index 00000000..ff558f63 --- /dev/null +++ b/.cursor/cloud-setup.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Cursor Cloud environment setup: deps are installed by environment.json's +# `install`; this script brings up the local Postgres and builds the MINIMAL +# synthetic database (docs/AGENT-OPS.md — agents never see stage/prod URLs). +set -euo pipefail + +sudo service docker start + +if [ ! -f .env ]; then + sed 's/YOUR_SECRET_HERE/cursor-cloud-local-secret/' .env.example > .env + echo "[cloud-setup] wrote .env from .env.example (local-only values)" +fi + +pnpm db:start + +# Dev database (teqo) and test database (teqo_test) — compose creates both. +pnpm migrate +DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate + +pnpm db:seed:minimal +DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm db:seed:minimal + +echo "[cloud-setup] OK — minimal database ready (dev + test)." diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 00000000..b22f7c13 --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,4 @@ +{ + "install": "pnpm install --frozen-lockfile && bash ./.cursor/cloud-setup.sh", + "start": "sudo service docker start && pnpm db:start" +} diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 7f4d076f..c7bfe44d 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -3,7 +3,7 @@ "hooks": { "preToolUse": [ { - "command": "node \"/Users/francisco.solla/.cursor/skills/impeccable/scripts/hook-before-edit.mjs\"", + "command": "node \".cursor/hooks/guard-env-files.mjs\"", "timeout": 5 } ] diff --git a/.cursor/hooks/guard-env-files.mjs b/.cursor/hooks/guard-env-files.mjs new file mode 100644 index 00000000..97208aeb --- /dev/null +++ b/.cursor/hooks/guard-env-files.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Portable write guard (repo hooks.json, preToolUse on Write/Edit). + * + * Denies agent writes to local env files (`.env`, `.env.local`, `.env.test`): + * they carry DATABASE_URLs and secrets, and the parallel-ops contract says + * agents never repoint databases (stage/prod URLs live only in CI secrets and + * human-run scripts). Humans editing by hand are unaffected — this only gates + * tool calls. + * + * Contract (same as the impeccable hook): never break a turn accidentally — + * malformed input or internal errors allow the tool and exit 0. + */ + +const DENY_PATTERN = /(^|\/)\.env(\.local|\.test|\.production)?$/ + +let input = '' +process.stdin.on('data', (chunk) => (input += chunk)) +process.stdin.on('end', () => { + try { + const payload = JSON.parse(input) + const filePath = payload?.tool_input?.file_path ?? payload?.tool_input?.path ?? '' + if (typeof filePath === 'string' && DENY_PATTERN.test(filePath)) { + process.stdout.write( + JSON.stringify({ + decision: 'deny', + reason: + 'Agentes não editam arquivos .env* (contrato agentic ops: nunca repontar DATABASE_URL; stage/prod só em secrets de CI e scripts humanos).', + }), + ) + process.exit(0) + } + } catch { + // malformed input → allow + } + process.exit(0) +}) diff --git a/.cursor/rules/engineering-standards.mdc b/.cursor/rules/engineering-standards.mdc index 2bb8df18..b4cc365f 100644 --- a/.cursor/rules/engineering-standards.mdc +++ b/.cursor/rules/engineering-standards.mdc @@ -11,7 +11,7 @@ Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IM 1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive. 2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment. -3. **Full gate before handoff:** `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check` (Prettier + organize-imports is the formatting authority — never hand-align), `pnpm exec knip`, `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int, local DB only), `pnpm build`. CI (`.github/workflows/ci.yml`) runs the same on the GitHub mirror. +3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (antes de cada push): `pnpm lint`, `pnpm exec tsc --noEmit`, `pnpm test:unit` (+ `pnpm format:check` e `pnpm check:cycles` antes de commitar — o pre-push hook cobre os três primeiros). Se a entrega tocou schema: também `pnpm migrate && pnpm db:seed:minimal && pnpm test:int`. **Gate completo = CI do PR** (`ci-pr.yml`: os anteriores + knip + int + build, contra o DB mínimo). O handoff humano (promote `stage→main`) exige CI stage + ci-pr green — não o build local. 4. **Run gate commands bare, never piped** (`pnpm test | tail` swallows the exit code). Never run `knip --fix` blind — verify each removal with `git grep -w ` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3). ## Type honesty diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 00000000..ae438354 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,20 @@ +# Cursor ignore — keep the always-on context lean (agentic ops, 2026-07-30). +# Heavy reference material is read on demand, not indexed into every run. + +# Research corpus (~67 sources) and discovery material +docs/research/ +docs/general-coordinator-interview/ +docs/design-refs/ +docs/sheets/ + +# Committed derived artifacts (read through their loaders, never raw) +src/lib/electionAggregates/ +src/lib/geometries/ +tests/fixtures/ + +# Download caches and build output +data/ +.next/ + +# Historical changelog (read on demand) +docs/CHANGELOG-AGENTS.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..2a4d59e2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## O que é + + + +Closes # + +## Contrato da entrega + +- [ ] Fast gate local passou: `pnpm lint` (0 warnings), `pnpm exec tsc --noEmit`, `pnpm test:unit` +- [ ] `pnpm format:check` e `pnpm check:cycles` limpos +- [ ] Órfãos da mudança deletados na mesma entrega (knip em CI confirma) +- [ ] **Se tocou schema** (migration, collection, campo, Consent key, dado de boot): `db:seed:minimal` atualizado **neste PR** (`scripts/lib/seed-minimal-manifest.mjs` + pin unit) — senão a label `needs:migration` está errada +- [ ] Nenhum `DATABASE_URL`/`ALLOW_REMOTE_DB` de stage/prod em script ou workflow +- [ ] `pnpm build` NÃO aparece em workflow que carregue `STAGE_DATABASE_URL` +- [ ] Se muda o que o agente precisa saber sempre: `AGENTS.md`/kernels atualizados; entregas registram UMA entrada curta em `docs/CHANGELOG-AGENTS.md` (nunca no AGENTS.md) + +## Notas para review + + diff --git a/AGENTS.md b/AGENTS.md index 272f2b4a..fc611002 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,104 +35,10 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si - To (re)populate news content, `pnpm db:seed:posts` fetches articles live from jorgesolla.com.br into the LOCAL db (same non-local `DATABASE_URL` guard as `pnpm dev`). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the production `posts` cache via `POST /api/revalidate`. See "Posts & Tags" below. - To (re)populate the TSE 2022 election baseline, `pnpm db:seed:tse` downloads TSE open data and imports the Bahia scope into the LOCAL db (same guard). Since the 2026-07-23 hardening, elections-tab reads are cached under the `election-tse` tag — after re-seeding a database the deployed app reads, bust it via `POST /api/revalidate?tag=election-tse`. See "Election baseline data (TSE 2022)" below. -## Known Gaps (as of 2026-07-24 — resolve before relying on this file for onboarding new devs) -Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.md`](docs/roadmap.md). +## Histórico de entregas (movido) -1. **RESOLVED 2026-07-27 (`users.roles`):** campo `roles` (`admin` | `editor`) em `users` — migration `20260727_032523_add_users_roles` com backfill `admin`. `isPayloadAdmin` exige papel `admin`; editor entra em `/admin` e escreve `post`/`tag`/`media`; PII/`petition`/campanha seguem admin-only. Unificação com `campaignUser` rejeitada (ver [roles-admin-payload.md](docs/plans/roles-admin-payload.md)). Matriz fina por collection e editor em `petition` ficam fora. -2. **RESOLVED 2026-07-25 (Pass 2 D3):** the last hardcoded consent document id is gone — `submitWhatsapp.ts` now resolves `Consent.key = 'whatsapp-inscricao'` fail-closed (migration `20260725_170000_whatsapp_subscription_consent_key` tags the live document; keys live in `src/lib/campaignConsentKeys.ts`). -3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas. -4. **Campaign LGPD consent texts are not yet configured in production.** Invites and self-service leadership flows fail closed until an admin creates `Consent.key = 'lideranca-autopreenchimento'` with counsel-approved copy. The C2 supporter registry (merged to `main`, production-blocked) additionally requires `Consent.key = 'apoiador-cadastro'` and `Consent.key = 'apoiador-intencao-voto'` (vote intention is sensitive data) — same fail-closed behavior. All four keys are bundled in the single legal batch of Onda 0 (see deploy checklist). - -**Recently resolved (2026-07-18):** the `/campanha` nuclei MVP shipped (auth/RBAC, nuclei, leaderships, vote estimates, updates, WhatsApp invites, dashboard) with consolidated migration `20260718_010733_consolidate_campaign_schema`. Later the same day, cycle 2 was merged and deployed: multi-municipality/neighborhood territory arrays (migration `20260718_190559_territorio_multi_municipio_bairro`), TSE 2022 election baseline collections + `pnpm db:seed:tse` (migration `20260718_195854_add_election_results`), the filtered nuclei-list overview panel, the nucleus share dialog, and the installable `/campanha` PWA. Still on 2026-07-18, **A2** shipped: static Bahia municipality↔TSE zone map (`src/lib/bahiaTseZones.ts` from TSE 2024 `detalhe_votacao_munzona` BA) plus opt-in territory/zone suggestion chips in the nucleus form (`territorySuggestions`, `NucleusTerritoryAndZonesFields`) — no migration, no forced equality on save. Also the same day: **C2** Cadastro nominal de apoiadores (`supporter` join `Contact`↔campanha with optional `nucleus`, migration `20260718_222656_add_supporter` with `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent keys `apoiador-cadastro` / `apoiador-intencao-voto` resolved via a generalized `campaignConsent.ts` that fails closed, `/campanha/apoiadores` vertical with list+KPIs/detail/CSV-import wizard; phone required in v1, `lideranca` has no access to the area; production use with real data still waits on Consent keys + legal sign-off). Also the same day: C3 Planos de Ação (`actionPlan` + `/campanha/planos`, migration `20260718_222832_add_action_plan`, blocos "Próximos eventos") — **renamed to `activity` / `/campanha/atividades` by C13 on 2026-07-25**; the old names survive only in frozen migration history. Also the same day: **B2** map foundation — static TopoJSON for Bahia municipalities + identity territories, `bahiaMunicipalityCodes`, helpers in `bahiaGeometries`, re-runnable `pnpm build:geometries` (no migration, no UI; Leaflet is B3). Post-`/simplify` scale follow-ups for that delivery are registered as **B5** (`docs/plans/escala-dry-pos-b2.md`). Earlier (2026-07-15): local Postgres via `docker-compose.yml` (Postgres 17, `teqo_test`); Payload migrations baselined; dev/test database guards; `post`/`tag` news routes deployed (see "Posts & Tags" below). - -**Recently resolved (2026-07-18 evening / 2026-07-19):** **A4** Baseline no produto + Gap vs 2022 shipped and merged to `main` — `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` aggregate TSE 2022 by nucleus geography; `computeGapVs2022` + `NucleusInsights` on the overview tab; `NucleusElectoralBaseline` card (candidate / president / governor via `BASELINE_TICKET_2022`); "Baseline 2022" card on the filtered nuclei-list overview. No migration (reads existing A3 collections). A `/simplify` pass applied targeted cleanup (role-generic ticket config, per-city `zoneNumber in […]`, shared geography helpers). Scale/DRY debits larger than cleanup (aggregate federal ranking on detail instead of loading all nominal rows, filter by TSE `cityCode`, Alert `confirmed` + `Progress` reuse) are registered as **A7** (`docs/plans/escala-dry-pos-a4.md`). - -**Recently resolved (2026-07-19):** **C6** Escala e DRY pós-C2 shipped and merged to `main` — bulk supporter import via `payload.db.drizzle` inside the existing Payload transaction (`supporterImportBulk.ts`, `ON CONFLICT DO NOTHING` on `(contact_id, nucleus_id)`, 500-row chunks), `skipContactPhoneInvariant` opt-in on `Contact.enforceUniqueContactPhone` (fail-closed: only honored inside an active `req.transactionID`), single-SQL KPI aggregate (`supporterListOverviewAggregate.ts`, `COUNT(*) FILTER` mirroring `buildSupporterListWhere` + access constraint), HMAC-SHA256 single-use import token + ephemeral `supporterImportBatch` staging collection (migration `20260719_011015_add_supporter_import_batch`, TTL 10 min, actor-bound, consumed on confirm), and shared shells (`campaignListUrl.ts`, `CampaignListPagination`, `campaignFormFields.ts`, `mapCampaignFormActionError.ts`) replacing the three per-list pagination components. A `/simplify` pass applied targeted cleanup (identity maps, dead branches, `relationshipId`/`canManageCampaignUsers` reuse, native `base64url`, `rowCount ?? 0` bug fix). Scale/DRY debits the simplify reviewers flagged as larger than cleanup (locks in 1 round-trip, `.returning()`, `pg_trgm`, shared `drizzleBulk.ts`, column-name assertion, migrating the remaining `formActions`) are registered as **C8** (`docs/plans/escala-dry-pos-c6.md`). Production use with real supporter data still waits on the Onda 0 legal batch (Consent keys `apoiador-cadastro` / `apoiador-intencao-voto`). - -**Recently resolved (2026-07-21):** **B6** `BahiaMap` setStyle incremental — layer GeoJSON estável entre troca de ano/métrica/escala; `pathByKeyRef` + restyle O(2) no hover/select; `fitBounds` só em `mode`/`highlightKeys`; helpers em `src/lib/bahiaMapStyle.ts`. Fecha débitos de perf de B10/B11. Plano: `docs/plans/escala-dry-pos-b3.md`. - -**Recently resolved (2026-07-21):** **B11** Escala % dos válidos no Mapa das Praças — `validVotesByYear` in `MunicipalityMapBundle`, `scaleMode` selector (`absolute` | `percentValid`, default %), `computeValidVoteShares`, readout em % com votos absolutos secundários; `/simplify` fix `scaleMax` (legenda 0–100% alinhada ao fill). Plano: `docs/plans/escala-percentual-mapa-pracas.md`. - -**Recently resolved (2026-07-21):** **B12** Aproximar mapa ao footprint filtrado + correção hover — `fitToKeys` / `interactiveKeys` em `BahiaMap` (`municipalitiesByIbgeCode`); `canonicalMapKeysKey`; hover stroke-only; clear síncrono no mouseout; fit só quando footprint muda. Plano: `docs/plans/aproximar-mapa-pracas.md`. - -**Recently resolved (2026-07-21):** **Discovery "Inteligência de campanha"** — ciclo literatura→persona→entrevista sintética aprovado. Embasamento canônico de produto/domínio em **`docs/research/`** (compêndio de ~67 fontes sobre campanha de DF na Bahia; relatório com kernel, playbook de 25 padrões dado→decisão, anti-goals): agentes devem consultá-lo antes de propor métricas/análises territoriais novas. Programa registrado no roadmap: **E8–E16 + B13 + C12** (+ adjacentes A11/E17; sessão real com o CG em 2026-07-23 validou/calibrou o programa — `docs/CUSTOMER.md`; plano-mestre `docs/plans/inteligencia-campanha.md`) — conta da cadeira (metas derivadas sobre válidos projetados), fila de alocação, registro-fundação (versions em `votePledge`, sinais tipados, `allocationDecision`), classificação relativa (substitui limiares 35/20/10 p/ DF), escala relativa no mapa (quantis/LQ/rank + símbolo proporcional), níveis N0–N4 staff-only, motor de sugestões com humano no loop, camada TI (salvaguardas MAUP), planejador de giros, backtest pós-eleição. Anti-metas travadas: % estadual absoluto como KPI, contagem bruta de cadastros, cartograma, "caça ao alienado", sugestão automática sem decisão humana. - -**Recently resolved (2026-07-23):** **Export CSV de assinaturas e contatos** no admin Payload — `@payloadcms/plugin-import-export@3.82.0` habilitado em `signature` e `contact` (`import: false`, export CSV síncrono); flatten de `contact`/`petition` via `toCSV` em [`src/utilities/signatureExport.ts`](src/utilities/signatureExport.ts) (só assinaturas); `consent` excluído do export de assinaturas; collections `exports`/`imports` com access `isPayloadAdmin`; migration `20260723_025513_add_import_export_plugin`. Plano: [`docs/plans/exportar-csv-assinaturas.md`](docs/plans/exportar-csv-assinaturas.md). - -**Recently resolved (2026-07-23):** **Engineering-standards hardening** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md); ledger: [`docs/TECH-DEBT.md`](docs/TECH-DEBT.md); safety net: [`docs/TESTING.md`](docs/TESTING.md); standards rule: `.cursor/rules/engineering-standards.mdc` — READ IT before coding). Phases: (0) explicit `access` on every collection — `users`/`signature`/`subscription`/`consent` admin-only, CMS writes admin-only, leader route gates on `demandas*`/`planos*`, `createLeaderSupporter` scope fixed, `candidate` treated as unrestricted staff consistently; (1) tooling gate — knip in CI (`.github/workflows/ci.yml` on the GitHub mirror), `pnpm lint --max-warnings=0`, `as never` banned (typed helpers `hookFilledCreateData` / tests' `stub`), tsconfig include consolidated + `noUnusedLocals`, ~12 dead modules deleted; (2) caching — committed TSE aggregate artifact (`pnpm build:election-aggregates` → `src/lib/electionAggregates/`, loader `bahiaElectionAggregates.ts`), `loadMunicipalityScope` React `cache()` dedup, `unstable_cache` + `election-tse` tag on elections-tab loaders; (3) "Feel the action" gaps closed — task-toggle revalidation bug, shared `CampaignListPendingBoundary` dimming results, map-compare transition, dashboard map + detail tabs stream behind Suspense; (4) state scoping — map hover isolated in `MunicipalityMapSelection`, scenario provider narrowed, `LeaderContactsPanel` server-rendered with a client form island; (5) structure — `campaignAccess` split into `src/utilities/access/*` (re-exported), supporter actions split (`supporterImport.ts`), `runStaffEntityMutation` + `runCampaignRedirectFormAction` dedupe the org/dobradinha twins and 4 formActions ladders, Plaza→Municipality identifier rename finished. - -**Recently resolved (2026-07-24):** **A11** Posição em votos do município — rank denso sobre as 435 unidades do catálogo + **% da própria votação** estadual (artefato `bahiaElectionAggregates`); helper `src/lib/municipalityVoteRank.ts`; UI no `MunicipalityBaselineCard` (por ano) e coluna/ordenação `?sort=votos` na lista (`sort`/`dir` mínimo `name`|`votos`; B15 amplia as demais keys). Plano: [`docs/plans/ranking-votos-municipio.md`](docs/plans/ranking-votos-municipio.md). - -**Recently resolved (2026-07-24):** **E4R** Import único da planilha de projeção — `pnpm db:seed:projecao` (`scripts/import-projecao.mjs`) lê o xlsx canônico em `docs/sheets/`, faz match via `canonicalizeMunicipalityName` + `municipalityCatalog`, e **sempre sobrescreve** `municipality.expectedVotes` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). **Fase 2 (mesmo dia, decisão do produto — não é mais zero PII):** o seed também importa as colunas de estratégia, com PRIORITÁRIAS sobrepondo MAPA GERAL quando não-vazia — SITUAÇÃO→`politicalTrend` (QUEDA/MANTÉM/AUMENTO→desfavorável/neutra/favorável, com nota de proveniência), DOBRADINHAS→`stateDeputy`+`stateDeputies` (união; célula crua em `dobradinhaNotes`), LIDERANÇAS→`contact` name-only **sem telefone** + `leadership` (dedup estadual por slug do nome; `Contact.phone` virou opcional via migration `20260724_175500_contact_phone_optional`, UI continua exigindo), ASSESSOR RESPONSÁVEL→`campaignUser`+`advisors` (união; Edizio=coordinator, Solla=candidate — `candidate` agora é elegível como assessor em `eligibleCampaignStaffWhere`; novos usuários com email `@planilha.invalid` + senha aleatória, sem login até um admin trocar), ENCAMINHAMENTOS→`nextSteps`, OBSERVAÇÃO→`strengths`/`risks` (classificação curada por slug). Relações são união (nunca removem vínculos da UI); re-run idempotente; nomes de atores políticos entraram sem trilha de consentimento — regularizar com a assessoria jurídica. No mesmo dia, o grupo duplicado `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido** — collection, zod, view models, form/card e utilitário `voteGoals.ts` — via migration `20260724_133600_drop_municipality_vote_goals` (backfill metas→estimativas onde a estimativa estava vazia): `expectedVotes` (pessimista/média/otimista) é a única série por cenário do município. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md). - -**Recently resolved (2026-07-25):** **B21 Página dos Territórios de Identidade** — nova rota staff-only `/campanha/territorios` com os 27 TIs no sistema de listas: sort/filtros/busca canônicos na URL, Metropolitano decomposto em sub-linhas, primeira coluna sticky no mobile e linha → `/campanha/municipios?region=`. O chrome rico agora é compartilhado por `CampaignSortableHead`/`CampaignHeaderFilterPopover`; wrappers de municípios mantêm a política de domínio. O painel de TI e `territorySlot` foram removidos do Início. Sem migration/collection/action. Plano: [`docs/plans/pagina-territorios-identidade.md`](docs/plans/pagina-territorios-identidade.md). - -**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e. - -**Recently resolved (2026-07-30):** **B77** Simplificar wizard ajuste de votos — `WizardExpectedVotesStep` reescrito: três cenários numa tela (`VoteEstimateScenarioInputs` compact + strip + atalhos no foco); validação `pessimistic ≤ central ≤ optimistic` só no confirm com `Alert` + highlight do par conflitante (`getWizardVoteViolationHighlights`); POST batch inalterado (`MUNICIPALITY_EXPECTED_VOTES_ENDPOINT`); `?cenario=` legado redireciona para URL canônica sem param (`hasWizardScenarioParam` + `wizardActionHref` sem 3º arg); órfãos do ritual B61 removidos de `wizardVoteEstimate`/`campaignWizardCopy`. `VoteEstimateScenarioInputs` compact ganhou `activeScenario`/`onFocusScenario`/`errorScenarios`/`disabled` (lista inalterada). Sem migration. Plano: [`docs/plans/simplificar-ajuste-votos-wizard.md`](docs/plans/simplificar-ajuste-votos-wizard.md). - -**Recently resolved (2026-07-30):** **B70** Wizard atualizar liderança — `/campanha/acoes/atualizar-lideranca` substitui o stub B60 por `WizardLeadershipStep` (grid 2/3/4 + tile Adicionar + info Drawer + Tooltip de `notes` no hover fine) e `WizardLeadershipForm` (6 campos incl. `exclusive` B69); `loadWizardLeadershipTiles` no RSC; writes `updateLeadershipWizard`/`createLeadershipWizard` em transação Contact+Leadership (não `updateLeadershipInternal`); `WIZARD_ENTRY_ACTION_QUERY_KEY` + skip via `trailingAction` quando `entryAction !== 'update-leadership'`; Continuar após Salvar dirty → Início + toast; `truncateNameAtWordBoundary` em `lib/`. Sem migration. Plano: [`docs/plans/wizard-atualizar-lideranca.md`](docs/plans/wizard-atualizar-lideranca.md). Desbloqueia wiring A1 embutido. -**Recently resolved (2026-07-30):** **B71** Polimento visual dos resultados de busca no Início — `src/lib/homeSearchUi.ts` centraliza bleed (`-mx-4 px-4 md:-mx-6 md:px-6`), heading discreto (`text-xs` sentence-case) e `ul list-none`; `HomeSearchHitRow` pinta hover/focus na faixa full-bleed; todos os `HomeSearch*Group` alinhados. Sem migration/action/contrato JSON. Plano: [`docs/plans/polimento-resultados-busca-inicio.md`](docs/plans/polimento-resultados-busca-inicio.md). - -**Recently resolved (2026-07-30):** **B69** Simplificar modelo de liderança — `leadership.exclusive` boolean NOT NULL DEFAULT true ("Apoio exclusivo") via migration `20260730_043306_simplify_leadership_fields` + DROP `sector`/`sectorNotes`/`consentNote` (collection, zod, forms, filtros/sort B29 da lista, invite autofill/redemption, dossiê; `?sector=` legado canonicaliza fora). **Decisão de produto 2026-07-30 reverteu o rascunho: `organization` FICA** — mapeia ramificações políticas (SindMed com várias lideranças); vertical `/campanha/organizacoes` e todos os vínculos intactos. Lista ganha coluna "Apoio exclusivo" (badge outline "Não exclusivo" só quando false); forms com checkbox default-on + hidden "false" na criação (último valor vence; ausência cai no default do zod). Nota operacional: o prompt interativo de rename do `migrate:create` trava sem TTY — gerar drops e adds em passadas separadas (`CI=1`) e fundir à mão foi o caminho; o `NOT NULL` foi emendado sobre o SQL gerado. Gate: tsc/lint/format/cycles/build; int 518/518; unit com as mesmas 16 falhas pré-existentes do clean tree (drift B66/B71 na base); knip idêntico à base (P3 + drift); Aikido 0 findings. Plano: [`docs/plans/simplificar-modelo-lideranca.md`](docs/plans/simplificar-modelo-lideranca.md). Desbloqueia **B70**. -**Recently resolved (2026-07-29):** **B62** Simplificar modelo de sinal — `municipalityUpdate` kind `sinal` is now `signalType` + `body` only: migration `20260730_010601_simplify_municipality_signal_fields` drops `signal_source` and `triangulated` (the generator re-emitted `ADD VALUE 'adiada'` from snapshot drift — removed by hand; this migration's committed `.json` heals the chain), and the five signal-type labels/descriptions were rewritten for the field team (`Rede esfriou`, `Adversário apareceu`, `Alguém pediu algo` — no "broker" in the signal model; `activity.origin`'s `Pedido de broker` and suggestionCatalog copy were explicitly left out by session decision). `MunicipalitySignalFields` (B26 list control) and the inline block in `MunicipalityUpdateForm` lost Fonte/Triangulado; `municipalityUpdatePageData` + `MunicipalityUpdateFeed` (detail + dossier) render only the type badge. **E11 changed semantics: any adversary signal in the window reads as confirmed — P1 triage 1** (session decision, replacing the triangulated/single-source split), `MunicipalityTriggerInput.adversarySignal: { present: true } | null`, single factor copy, E18 glossary aligned. Bonus gate fixes: the `campaignMunicipalities` "advisor scopes…" e2e was stale since B43 (leader home → `/campanha/contatos`) and was updated, and the `municipalityElectoralBaseline` bypass pin dropped 9→8 (pre-existing main drift). Gate: 876 unit / 489 int / targeted e2e green / build; Aikido 0 findings. Plano: [`docs/plans/simplificar-modelo-sinal.md`](docs/plans/simplificar-modelo-sinal.md). -**Recently resolved (2026-07-29):** **B59** Chassis do wizard de campanha — rotas staff em `/campanha/acoes/` (`campaignActionRoutes.ts`, `campaignActionEntryHref`), layout `acoes/layout.tsx` (gate staff, coluna `max-w-lg`), `CampaignWizardShell` (voltar mobile, caption de município sticky, `trailingAction`, foco no `h1` ao trocar passo), cinco páginas-ponte até B60 e strip do Início com `href` nos fluxos wizard. Sem migration. Plano: [`docs/plans/chassis-wizard-campanha.md`](docs/plans/chassis-wizard-campanha.md). - -**Recently resolved (2026-07-29):** **B49** Busca global — resultados Lideranças no Início staff: o mesmo `POST /campanha/home-search` agora inclui `searchHomeLeaderships` em paralelo com municípios e assessores; word-start no `contact.name` com pré-filtro `buildLeadershipListWhere` (`contains`) refinado em memória, cap 25, `municipalitiesSummary` via `truncatedNamesLabel`, desempate por `updatedAt`; assessor vê só a carteira (`overrideAccess: false`). Client: `HomeSearchLeadershipGroup` entre Municípios e Assessores, `HomeSearchHitRow` compartilhado, empty state via `homeSearchHasAnyHits`; linha → `/campanha/liderancas/[id]`. Sem migration. Plano: [`docs/plans/busca-global-resultados-liderancas.md`](docs/plans/busca-global-resultados-liderancas.md). - -**Recently resolved (2026-07-29):** **B48** Busca global — resultados Municípios (+ TIs) no Início staff: `POST /campanha/home-search` (`campaignJsonMutationRoute` + `homeSearchBodySchema`) chama `searchHomeMunicipalities` — municípios via `loadMunicipalityScope` + `matchesAtWordStart` + readout A11; territórios no mesmo grupo via `loadTerritoryOverview` (contexto regional E17, não carteira do assessor). Client: `HomeSearchResultsContext` (fetch único, extensível B49+), `HomeSearchMunicipalityGroup`, empty “Nenhum resultado.”; `MunicipalityVotePositionReadout` extraído de `MunicipalityList`. Sem migration. Plano: [`docs/plans/busca-global-resultados-municipios.md`](docs/plans/busca-global-resultados-municipios.md). - -**Recently resolved (2026-07-28):** **E11** Motor de sugestões v1 — the 8 highest-confidence data→decision patterns of the research report §6.1 (P1 reduto dormente/ameaçado, P2 ataque, P3 correligionário na frente, P5 expansão, P6 grande sem rede, P7 pledges estagnados, K-A não responde, K-B perdida vestida) now run as a human-in-the-loop decision queue: a "Sugestões" panel on the staff dashboard (top-5 by triage + "pauta do silêncio") and a card on the municipality Overview, with aceitar/adiar/descartar writing `allocationDecision`. Three layers: **`src/lib/suggestionCatalog.ts`** (client-safe — pure predicates over `MunicipalityTriggerInput`, a numbers-only input type, plus the exact card copy: probable reading, the "descartes" that become the dismiss form's quick picks, cheap-before-expensive menu, contraindication, statute; anchors are illustrative and private, calibration = E15); **`src/utilities/municipalityTriggers.ts`** (`server-only`, on the E13 planner's mold — own municipality read because the shared scope select lacks `engagementLevel`/`lastUpdateAt`, six reads in one `Promise.all`, and per-year LQ using E10's exact formula rather than a third LQ writing); **`resolveSuggestionRecord`** which RE-EVALUATES the pattern server-side before writing — the snapshot records decision-time factors, never render-time ones, and a pattern that left the queue refuses with the safe `SUGGESTION_STALE_MESSAGE`; advisory lock `municipality-suggestion:{id}:{patternId}` is taken before re-reading the latest decision. **The audit killed the plan's "sem migration":** the real outcome enum was `aceita|descarta|movimento` (the plan's `descartada|adiada` values never existed), so "Adiar" cost migration `20260728_041547_add_allocation_decision_adiada_outcome` (E14's `movimento` precedent), with `postponeDays` derived from the recomputed triage (7d; 14d on levels 4–5) and `suppressUntil` in the snapshot instead of a column. Suppression is pure and unit-pinned (`isSuggestionSuppressedByDecision`): aceita 14d, descarta 30d, adiada until snooze, malformed snapshot falls back to the shortest postpone — and **"nível 1 fura a fila"**: a trigger returning as confirmed risk pierces a suppression recorded at lower urgency. Structural differential is code (§6.3): P2 yields to P3/K-B, P7 yields to K-A when effort was recorded. P3 fires from the artifact alone (`runningAgain2026` is 100% `desconhecido` with no read path — "concorre de novo?" is the menu's first item, not a trigger). Silence review = alta/N2+ with no FIRED pattern (a suppressed one was answered, not silence) and no signal ≥30d; includes `priority: alta` because E14 shipped without backfill. UI: explicit submit on all three outcomes (the `campanha-edit-where-you-see` exception for note/confirmation flows), native radios (`accent-primary` — first radio precedent in the repo), managed focus across the trigger↔form swap, level 1 is the only red. **Two facts worth keeping:** the public-site base `ul { my-6 ml-6 list-disc }` leaks into campaign surfaces (the factors list needed B20's reset), and artifact-anchored patterns cannot be staged from DB rows — int tests that need an artifact property claim municípios through the fixture allocator until one qualifies (measured: 109/435 top-quartile, 205/435 quiet, and reduto+coreBlock is only 13/435, which moved P1's trigger coverage to the 45-case unit spec; explicit slugs would race the allocator's wrapping sequence). E18 concepts `triagem-de-sugestoes` + `pauta-do-silencio`; `/campanha` First Load JS 268 → 277 kB. Adjacent follow-ups live in the plan: wiring E13's "não vá" override to `resolveSuggestion`; catalog fase 2 (T/I/J, P4/P8–P12, K-C) after 2 weeks of real use + 20 decisions; a dedicated route if >15 recurring active suggestions. Plano: [`docs/plans/motor-de-sugestoes.md`](docs/plans/motor-de-sugestoes.md). - -**Recently resolved (2026-07-28):** **B32+** Escala e DRY pós-B32 — and one line of it is a convention every agent needs: **every JSON POST route under `/campanha` is now created by `campaignJsonMutationRoute(config, handler)`** (`src/utilities/campaignJsonMutationRoute.ts`), which exports the `POST` already same-origin-checked, body-parsed and error-mapped, and a convention guard in `tests/unit/codebaseConventions.unit.spec.ts` fails the build on a `route.ts` that exports `POST` without it — the guard sweeps **all of `src/app`**, not just `(campaign)`, because a cookie-authenticated endpoint written under `api/` would be exactly as exposed and exactly as invisible; the three exceptions (Payload's two re-exports, the secret-header `revalidate`) are declared with their reason. That wrapper replaced the helper the plan asked for, because the helper would still have been a line someone could forget: `isSameOriginRequest` was copied into five routes, so the CSRF policy it claims to own was **failing open by omission**. Two details not to undo: `bodySchema.parse` runs **inside the same `try`** as the handler (that is what keeps a malformed body answering with the route's existing `genericMessage` instead of a new one), and `engagement-level` keeps its 409 **inside** its handler so the wrapper never grows a "blocked" concept. `parseCampaignJsonRequestBody` and `campaignJsonMutationErrorResponse` are no longer exported. Client side: `postCampaignJson` (`src/lib/campaignJsonRequest.ts`) is the only place that spells `credentials: 'same-origin'` + JSON headers, and **`useCampaignCellAutosave`** (`src/components/campaign/shared/`) is the auto-save machine of the quick-edit cells (B24 tendência, B27 votos estimados, B32 status de apoio). **Three audit findings worth keeping:** `display` and `draft` never diverged — they were set together on every path, one value under two names, so the hook has one value and only the trend's raw `noteDraft` (raw text vs. normalized note) stays in the caller; `reportFailure` had **not** been born in all four controls as the plan claimed (`MunicipalityListAdvisorsControl` never imported `sonner`, so a failure after closing was mute); and the trend's `inFlightRef` was a workaround for having two code paths into one save, so it became an idempotent `flush()` that protects all three. The hook owns `open` because **closing is the commit**, and `valueRef` advances synchronously — the same "state captured by the render that scheduled the work" bug B33+ and B34 both paid for. Shell: `CampaignCellEditOverlay` took a `statusMessage` prop and renders the `aria-live` region **outside** the Popover/Drawer, because closing is what triggers the save and it was unmounting the node that would announce it; and `variant="sheet"` took a `footer` slot, which lifted "Registrar movimento" out of a scrollable body (E14) and let `MunicipalityListSignalControl` (B26) drop the Popover/Drawer it only kept for its footer — the planned `formId` prop turned out unnecessary, since `
` in the caller plus `