E14: engagement levels N0-N4 and the first allocationDecision writer.

`municipality.engagementLevel` (staff-only, no default) records what the
campaign decided to invest in a município, next to the E8/E10 metrics that
describe what it knows. Moving it is a reallocation decision, so only
unrestricted staff may, every move carries a rationale plus the signals that
would reverse it, and the write files an `allocationDecision` in the same
transaction under an advisory lock taken before the current level is read —
otherwise the snapshot's `from` is a guess. The enum gains `movimento`: a level
move is neither `aceita` nor `descarta`.

No backfill. Null is "Sem nível", and filtering by it is the triage queue.
That is also why the list sorts `nivel` in memory rather than in the query:
Postgres puts NULLs first on DESC, so a native sort would open "N4 primeiro"
on a page of municípios nobody has decided about yet. The filter stays native.

The hysteresis rules from research §6.8 are pure and client-safe, so the
Popover explains a block before submitting and the server re-runs them anyway;
a blocked move answers 409 with typed violations and the override is recorded
with exactly the rules it contradicted.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 21:16:03 -03:00
parent b839b924fb
commit c7001b75f0
37 changed files with 13782 additions and 40 deletions

File diff suppressed because one or more lines are too long

View file

@ -72,6 +72,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-27):** **E13** Planejador de presença/giros — visit eligibility as **five conditions ✓/—** (volume, headroom, rede, janela, encaixe) plus three advisory contraindications, each carrying a counter-offer and **never** blocking; the calendar phase (`construcao|consolidacao|ativacao`) changes the suggested "produto da visita". Pure math in `src/utilities/visitEligibility.ts`, cut points in `src/lib/visitPlannerAnchors.ts` (illustrative, on the `territorialClassAnchors.ts` precedent; the phase turns at midnight in Bahia via the new `formatBahiaCivilDate`), candidates grouped by identity territory in `src/utilities/visitPlannerData.ts` (`server-only`). Surfaces: an "Elegibilidade para visita" card on the municipality `OverviewTab`, and a composer at **`/campanha/atividades/giros`** (staff-only, reached from a "Planejar giro" button — no new sidebar entry) that turns TI → âncora/satélites/semente into draft `activity` rows in one transaction. **Three plan premises died in the pre-implementation audit and should not come back:** `kind: 'visita'` never shipped with C12 and is **not** wanted — `activity.deputyPresent` already IS the candidate-presence marker, so a giro is derivable from `deputyPresent` + território + date window and each stop keeps its real kind; `allocationDecision` still has no application write path, so the "não vá" is advisory text and recording an override is **E11**'s first job; and "satélites contíguos" was never possible — `bahiaTerritories.ts` carries membership only (polygon adjacency exists solely inside the build scripts), so **encaixe = same TI**, said in those words in the UI. Two implementation facts worth keeping: the uncaptured field ceiling and the catalog medians moved out of E10's classifier into `municipalityPotential.ts` (memoized), so the card and the "Classe" column cannot compute the same number twice; and `createTourDraftActivitiesRecord` re-reads every stop's município id with `overrideAccess: false` **inside** the transaction and aborts if one is missing, because `activity` create access is a plain staff boolean and Payload cannot express a per-município constraint on create — without it an advisor could seed drafts statewide by posting ids the composer never offered. Caps: 3 satellites, 8 stops. No migration, no collection, no `Consent`, no persisted `tour` entity. Plano: [`docs/plans/planejador-de-giros.md`](docs/plans/planejador-de-giros.md).
**Recently resolved (2026-07-27):** **`users.roles`** — Payload admin RBAC. Field `roles` (`admin` | `editor`) on `users` (migration `20260727_032523_add_users_roles`, backfill `admin`); `isPayloadAdmin` requires the `admin` role; editors open `/admin` and write `post`/`tag`/`media`; PII/`petition`/campaign collections stay admin-only. Unifying `users` + `campaignUser` was rejected (25 FKs; structural isolation). Plano: [`docs/plans/roles-admin-payload.md`](docs/plans/roles-admin-payload.md).
**Recently resolved (2026-07-27):** **E14** Níveis de envolvimento N0–N4 — `municipality.engagementLevel` (staff-only select `n0…n4`) records **what the campaign decided to invest** in a município, next to the E8/E10 metrics that describe what it knows. It is also the **first production writer of `allocationDecision`** (C12 shipped 2026-07-24 with access and tests but no caller). Migration `20260727_161752_add_municipality_engagement_level` adds `engagementLevel` (indexed, **no default**), `levelNote` (textarea 2000) and `levelChangedAt` (indexed date, `readOnly`, stamped by a `beforeChange` hook only when the level actually changes — and cleared when the level is), plus the value `movimento` on the `allocationDecision.outcome` enum, because a level move is neither `aceita` nor `descarta` and pretending otherwise would poison E11's triage. **No backfill:** the field is born null on all 435 municípios — stamping N2 would assert a decision nobody made — so null renders "Sem nível" and filtering by it **is** the triage queue. Moving a município is a reallocation decision, so writes are `isCampaignUnrestricted` only (`canManageMunicipalityEngagementLevel` in `utilities/access/municipalities.ts`); the advisor reads it and proposes through a signal. `setMunicipalityEngagementLevelRecord` takes the advisory lock `municipality-engagement-level:{id}` **before** reading the current level (otherwise the snapshot's `from` is a guess under concurrency) and writes both documents in one transaction, with `snapshot = { from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt }` — the values the decision was made on, never the document. The hysteresis rules from research §6.8 live in the pure, client-safe `src/lib/engagementLevel.ts` (two-level jump needs a triangulated shock; three-week protection window after a promotion, against demotion only; one move per calendar month, derivable from `levelChangedAt` alone — no extra query), so the Popover shows the reason before submitting and the server re-runs them anyway. **The route has three states, not two:** `blocked` (409) returns the typed violations instead of collapsing into "could not save", because a blocked move is a decision the coordination may still take — the Popover keeps the draft and offers the override, which is recorded with exactly the rules it contradicted. **Explicit submit, not auto-save** — the exception `campanha-edit-where-you-see` already carves out for flows needing a note/confirmation, which also keeps this control out of the debounce machine **B32+** will audit. On the list: an 11th staff column with a numbered outline badge, plus filter `?level=`, which — unlike E10's derived `classe` — is **native**, because the field is stored and reaches the `where`; "sem nível" cannot ride inside an `in` and becomes an `or` branch with `{ exists: false }`. **The sort is not native**, though `n0…n4` does order lexicographically: Postgres puts NULLs FIRST on DESC, so with no backfill "N4 primeiro" would open on a page of "Sem nível". `nivel` joins `applyDerivedMunicipalitySort` and reuses `sortByNullableValue`, which sinks the absent level in both directions — the same treatment `sem_base` gets in `classe`. New E18 concept `nivel-de-envolvimento`. **Worth knowing:** with `overrideAccess: false` Payload **silently drops** a field the actor cannot write instead of rejecting the update, so the advisor int test asserts the level did **not** change rather than expecting a throw. Plano: [`docs/plans/niveis-de-envolvimento.md`](docs/plans/niveis-de-envolvimento.md).
**Recently resolved (2026-07-27):** **Build resiliente ao global `metadata` ausente** — `resolveSiteMetadata` in `src/utilities/seo.ts` (chain `global.URL` → `NEXT_PUBLIC_SITE_URL` → omit canonical/OG/JSON-LD url; textual defaults for empty global). All public SEO consumers (`layout`, `/abaixo-assinado/[id]`, `/[type]` tree) go through it; convention guard in `codebaseConventions.unit.spec.ts`. Plano: [`docs/plans/build-resiliente-metadata-ausente.md`](docs/plans/build-resiliente-metadata-ausente.md).
**Recently resolved (2026-07-26):** **B8 F2** Polígonos dos Municípios-zona de Salvador (ZE 1–19) — Salvador used to be **one** polygon carrying the whole city's votes, so the choropleth could not answer "which piece of Salvador is weak, or mine"; the only per-zone view was the textual `zoneBreakdown`. Two halves shipped. **(1) The mesh:** a new committed artifact `src/lib/geometries/bahia-municipality-zones.topo.json` (72,310 bytes, ~20 KB gzip, 19 features keyed by `municipalitySlug`) built by `pnpm build:municipality-zone-geometries` — see "Campaign map geometries" for the source, the parser and the three curated reconciliation tables. The interesting part is not the dissolve, it is that **the curation lives in the script, declared line by line with its evidence, and the build fails closed**: an orphan polygon or a TRE neighbourhood with no polygon aborts it, so "170/170 → 19/19" is a tested invariant. Periperi, which the resolution splits mid-street between two ZE, goes **whole** to ZE 17 — the neighbourhood is the smallest geometry available and cutting a polygon from a prose description of a street was the rabbit hole the plan already refused. The 5 polygons the resolution never names are assigned by **longest shared boundary**, and that rule was _validated_ against the 4 it names under another label before being trusted. **(2) The key:** the choropleth is no longer keyed by `codarea` but by a **map key** (`mapKeyForMunicipality`: slug for `kind === 'zona'`, `ibgeCode` otherwise) — see the "Map" bullet in the Município model for the contract, the non-interactive Salvador base and the per-city rank limitation. Because each map key now maps to exactly one catalog entry, the rollups stopped accumulating with `+=` and `computeAggregateTerritorialClass` gave way to `computeMunicipalityTerritorialClass`; `resolveMunicipalityMapNavigation` became 1:1, and with it the `kind: 'zones'` branch, its "toque de novo" readout copy and the panel's scroll-anchor all died. `buildMunicipalitiesByIbgeCode` survives untouched: B14's list href is per city on purpose. **Two measurements, so nobody re-derives them:** Salvador's municipal polygon is **~690 km²** against **~324 km²** for the 19 zones summed — the difference is the bay water the municipal limit encloses and a neighbourhood mesh never draws, which is why the int test asserts a **land**-area range instead of a ratio against the município (the first version demanded ≥95% and was comparing land to land+water); and centroid-inside-another-zone is **not** a valid overlap check here, because ZE 16 is concave and wraps ZE 14, so 16's centroid legitimately falls in 14 — non-overlap is guaranteed structurally at build time plus the area sum. New e2e `campaignZoneMap.e2e.spec.ts` pins 436 paths / 435 interactive (the one non-interactive path is Salvador's base) and that a zone opens `/campanha/municipios/salvador-ze-N`. No migration, no collection, no server action, no `Consent`. **Open debts with triggers (in the plan, not silent):** the B14 "Onde estou" card still routes Salvador to the filtered list even though resolving the exact ZE is now possible, and per-ZE competitive rank needs a new dimension in the TSE artifact. Plano: [`docs/plans/poligonos-pracas-zona.md`](docs/plans/poligonos-pracas-zona.md).
@ -159,6 +161,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k
- **Município definition:** 435 seeded rows — one per Bahia municipality (416), except Salvador (19 zone municipalities, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated). Canonical catalog: `src/lib/municipalityCatalog.ts` (`tests/fixtures/municipality-catalog.snapshot.json`). Seeded by migration `20260723_200000_remodel_municipalities` (+ reconcile `20260723_202000_reconcile_municipality_remodel`). Geography is admin-only; staff edit strategy fields and the coordinator assigns `advisors`.
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `allocationDecision`, `municipalityUpdate`, `campaignInvite`, `supporter`, `activity`. **Global** (same admin group): `campaignGoals` (E8 "conta da cadeira" — `stateGoal` sets the optimistic suggested-goal scenario, `margin` the pessimistic haircut; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; no `afterChange` hook — nothing caches it).
- **Engagement level (E14):** `municipality.engagementLevel` (`n0…n4`, staff-only, no default) is the campaign's declared investment in the município — N0 monitorar … N4 investimento pleno. Only `isCampaignUnrestricted` actors move it, every move needs a rationale plus the signals that would reverse it, and the move writes an `allocationDecision` (`outcome: 'movimento'`) in the same transaction under an advisory lock. Hysteresis rules are pure (`src/lib/engagementLevel.ts`) and shared by client and server; the override is recorded with the rules it contradicted. Null means "Sem nível" — the triage queue, not a missing value to backfill.
- **Roles:** `coordinator` ("Coordenador Geral") and `candidate` ("Candidato", unrestricted visibility via `isCampaignUnrestricted`) see/manage everything staff sees; `advisor` ("Assessor") is scoped to `municipality.advisors`; `leader` ("Liderança") is **lockdown** — home is the supporter contact tool only (`LeaderContactsPanel`, `source: lideranca`), reads only supporters they created (`createdBy`), no municipalities/leaderships/pledges/demands/plans/election data.
- **Vote pledge asymmetry:** staff declare `declaredVotes` and record `estimatedVotes` (pessimistic/central/optimistic); leaders never see estimates. Aggregates use `estimated[S] ?? declared`, default `central` (`src/utilities/votePledgeData.ts`).
- **Demandas:** staff-only create/read (`canCreateCampaignDemand`); optional `leadership` provenance selected by staff. Escalated decisions: coordinator **or** candidate.

View file

@ -1,12 +1,19 @@
# E14 — Níveis de envolvimento N0–N4 por município
Status: rascunho
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening)
Status: entregue (2026-07-27)
Atualizado em: 2026-07-27 (entregue: migration `20260727_161752_add_municipality_engagement_level`, action transacional com lock gravando `municipality` + `allocationDecision`, rota JSON com terceiro estado `blocked`, Popover de submit explícito, coluna/sort/filtro na lista, verbete E18 — as-built no fim do arquivo)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", E14; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md))
Impeccable: B — campo novo no detalhe/lista do município (Popover B9) + badge staff; sem rota nova
Appetite: ~1 dia eng; migration pequena (campos em `municipality`)
Impeccable: B — campo novo no detalhe/lista do município (Popover) + badge staff; rota nova só de mutação JSON (`POST /campanha/municipios/engagement-level`)
Appetite: ~1,25 dia eng; migration pequena (3 campos em `municipality` + um valor no enum de `allocationDecision.outcome`)
Responsável: —
## Dados → decisão → apresentação
- **Vou apresentar dados?** Sim — uma classificação decidida por humano (não derivada), em **badge categórico** de 5 estados + "sem nível", na célula da lista e no card de estratégia do detalhe.
- **Que decisão ela serve?** Onde a campanha coloca presença, rede e agenda nas próximas semanas — e o registro de por que mudou.
- **Forma escolhida:** pill **outline numerada** ("N3"), rótulo longo e motivo no `cellTooltip` (seam do B23 ✓). A linha já carrega prioridade (vermelho), classe (5 variantes) e tendência (3): o nível não pode competir por cor.
- **Anti-goals de dado:** escala de cor de "importância" (vira ranking público — anti-goal 11); qualquer visibilidade para `leader`; gráfico ou série do nível.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípios 3 e 5) / `DESIGN.md` (register `product`) · controles B9 (`MunicipalityList*Control`).
@ -25,24 +32,22 @@ Relatório §6.8: a escala N0 Monitorar → N1 Presença de mandato → N2 Rede
## Objetivos
- `municipality.engagementLevel` (`n0…n4`, default `n2`), `levelNote` (motivo corrente), `levelChangedAt` (derivado) — todos staff-only (access nega `leader` na leitura, mesmo mecanismo de `supportStatus`).
- `municipality.engagementLevel` (`n0…n4`, **sem default** — ver decisões), `levelNote` (motivo corrente), `levelChangedAt` (derivado) — todos staff-only na leitura (`canReadCampaignStaffField`, o mesmo mecanismo de `priority`/`politicalTrend`) e **unrestricted** na escrita.
- Mudança de nível SEMPRE grava `allocationDecision` (patternId `nivel`, snapshot com nível anterior/novo, motivo, sinais de reversão) — histórico auditável sem versionar `municipality`.
- Validações de movimento no server action (pulo de 2 níveis exige flag "choque triangulado" com nota; aviso de janela de proteção) — avisos bloqueiam por default com override explícito do coordenador (registrado).
- `priority` legada: mapeada (`alta`→N3 sugerido) e aposentada da UI; campo permanece no schema até limpeza futura (sem migração destrutiva agora).
- Consumidores: badge staff no detalhe/lista; E9 usa nível na ordenação (N0/N1 fora do topo do déficit); metas E8 leem nível para "meta mínima".
- Validações de movimento no server action (pulo de 2 níveis exige flag "choque triangulado" com nota; aviso de janela de proteção) — avisos bloqueiam por default com override explícito do coordenador (registrado no snapshot).
- Ordenação e filtro por nível na lista de municípios (chave `nivel`, param repetível) — o campo é armazenado, então o filtro é `where` do Payload; o sort roda em memória por causa dos nulos (ver as-built).
## Decisões travadas
- **Nível é staff-only fail-closed** (field access como `supportStatus`; nunca em view models de liderança). Vocabulário duplo é requisito de produto. **Rejeitado:** badge público "prioridade" (vaza o mapa de defesa).
- **Nível é staff-only fail-closed** (field access; nunca em view models de liderança). Vocabulário duplo é requisito de produto. **Rejeitado:** badge público "prioridade" (vaza o mapa de defesa).
- **Histórico via `allocationDecision`, não versions em `municipality`** (um mecanismo só para decisões — C12). **Rejeitado:** versions em municipality (custo alto, ruído); array `levelHistory` no doc (cresce sem access próprio).
- **`outcome` ganha um terceiro valor (`movimento`)** em vez de o movimento de nível se disfarçar de `aceita`. O enum nasceu binário (`aceita|descarta`) para a triagem de sugestões do E11, com `alternativeReading` obrigatória no descarte; um movimento de nível não é nenhum dos dois. **Rejeitado:** gravar sempre `aceita` (o campo viraria ruído semântico no primeiro consumidor da collection); usar `municipalityUpdate` kind `sinal` (misturaria decisão de alocação com sinal de campo e contaminaria `lastUpdateAt`/frescor).
- **Só `coordinator`/`candidate` movem o nível** (é decisão de realocação — rito §6.8); o assessor propõe por sinal. O access de `allocationDecision` já permite assessor no escopo dele, então a restrição vive na field access do campo + no `reloadUnrestrictedActor` da action. **Rejeitado:** assessor com aprovação (workflow multi-etapa é rabbit hole declarado abaixo).
- **Sem backfill: o campo nasce nulo, não `n2`.** Carimbar N2 em 435 municípios afirmaria uma decisão que ninguém tomou, e o rito §6.8 é registro ex-ante com motivo. "Sem nível" é um estado honesto, e filtrar por ele **é** a fila de triagem inicial. **Rejeitado:** default N2 no schema (o plano original) — reintroduz o binário que o item existe para substituir, agora com 435 falsos positivos. _(assumido — validar com produto)_
- **Submit explícito, não auto-save.** É a exceção que a regra `campanha-edit-where-you-see` já prevê ("flows que precisam de nota/confirmação"): o movimento exige motivo E sinais de reversão, e pode precisar de override. Efeito colateral bom: o controle não vira o 4º call site da máquina de debounce que o **B32+** ainda vai extrair. **Rejeitado:** auto-save por debounce como B24 ✓/B27 ✓/B32 ✓.
- **Rebaixar meta ≠ rebaixar município** (K-C): nível e meta são eixos independentes; a UI não acopla os dois automaticamente. **Rejeitado:** auto-derivar nível da cobertura (vira gaming imediato — G4).
- **i18n e naming:** `engagementLevel` (`n0|n1|n2|n3|n4`), `levelNote`, `levelChangedAt`; labels pt-BR ("N1 · Presença de mandato" etc.).
## Questões em aberto
- **Default N2 para todas ou derivar sugestão inicial dos dados (E8/E10)?** Opções: N2 fixo | sugestão computada com aceite em lote. **Recomendação:** N2 fixo + tela de revisão em lote ordenada por sugestão — decisão continua humana, custo de setup ~1h de reunião. _(validar com produto)_
- **Quem pode mudar nível?** Opções: só `coordinator` | `advisor` nos seus municípios com aprovação. **Recomendação:** só `coordinator` (é decisão de realocação — rito §6.8); assessor propõe via nota/sinal.
## Abordagem proposta
```mermaid
@ -51,41 +56,63 @@ flowchart LR
Action["setMunicipalityEngagementLevel<br/>(valida movimento)"]
Municipality["municipality.engagementLevel"]
Dec["allocationDecision<br/>(motivo + reversão + snapshot)"]
Consumers["E9 fila · E8 metas · E11 motor"]
ListUrl["Lista: sort e filtro por nível"]
UI --> Action --> Municipality
Action --> Dec
Municipality --> Consumers
Municipality --> ListUrl
```
Componentes:
Componentes (caminhos conferidos contra o repo em 2026-07-27):
- **`src/collections/Municipality.ts`**: 3 campos novos com access staff (padrão `politicalTrend`); `priority` sai da UI.
- **`src/utilities/municipalityStaffFormActions.ts`** (ou action irmã): `setMunicipalityEngagementLevel` transacional (municipality + allocationDecision com `req`), validações de histerese puras em `src/lib/engagementLevel.ts` (unit-testável).
- **UI:** `MunicipalityListLevelControl` no padrão dos `MunicipalityList*Control` (B9); badge no header do detalhe.
- **Migration:** `pnpm migrate:create add_municipality_engagement_level`.
- **`src/collections/Municipality.ts`**: 3 campos novos; leitura `canReadCampaignStaffField`, escrita `canManageMunicipalityEngagementLevel` (novo, em `src/utilities/access/municipalities.ts`, no molde de `canAssignMunicipalityAdvisors`); hook `beforeChange` carimbando `levelChangedAt`, espelhando `derivePoliticalTrendAudit`.
- **`src/collections/AllocationDecision.ts`**: terceiro valor em `outcome`.
- **`src/app/(campaign)/campanha/actions/municipality.ts`**: `setMunicipalityEngagementLevel[Record]` transacional (municipality + allocationDecision com `req`), com lock `municipality-engagement-level:{id}` via `acquireTextAdvisoryLocks` (`src/utilities/postgresTransactionLocks.ts`) — o "nível anterior" do snapshot precisa ser verdade sob concorrência.
- **`src/lib/engagementLevel.ts`** (novo, puro/client-safe): rótulos, peso ordinal e as violações de histerese (unit-testável).
- **Rota:** `POST /campanha/municipios/engagement-level` (`route.ts` + `types.ts`), espelhando `municipios/political-trend/`.
- **UI:** `src/components/campaign/municipality/MunicipalityListLevelControl.tsx` (Popover com submit explícito); badge no bloco staff de `MunicipalityStrategyCard.tsx`, ao lado de prioridade/tendência.
- **URL:** chave de sort `nivel` e filtro multi estático em `municipalityListUrl.ts` / `municipalityListFilters.ts`; coluna descrita em `municipalityLabels.ts`; verbete no `campaignIntelligenceConcepts.ts` (E18).
- **Migration:** `pnpm migrate:create add_municipality_engagement_level`. `ALTER TYPE … ADD VALUE` roda dentro da transação da migration (PG 12+) desde que o valor novo **não seja usado na mesma migration** — não backfillar aqui.
## Dependências
- Duras: **C12** (`allocationDecision` existir). Suaves: E8 (metas por nível), E9 (ordenação), E11 (nível no snapshot dos gatilhos).
- Reusa: `municipalityStaffFormActions.ts`, `campaignAccess.ts`, padrão de field access de `supportStatus` (`Leadership.ts`).
- Duras: **C12 ✓** (`allocationDecision` existe; o E14 é seu primeiro escritor de produção). Suaves: E11 (nível no snapshot dos gatilhos), B23 ✓ (`cellTooltip`), B41 ✓ (scroll horizontal, que é o que faz caber a 12ª coluna).
- Reusa: `actions/municipality.ts`, `municipalityStaffEditMessages.ts`, `campaignJsonMutationRoute.ts` + `isSameOriginRequest`, `campaignAccess.ts`, `withPayloadTransaction`, `acquireTextAdvisoryLocks`.
## Não escopo
- **Aposentar `priority` da UI** — o plano original previa isso; a auditoria de 2026-07-27 mostrou 18 arquivos em `src` envolvidos (atalho do Início `dashboardPriorityMunicipalities.ts`, link `?priority=alta&coverage=sem_assessor` do E9, filtro de header, card/form de estratégia, dossiê) **e o seed da planilha `projectionSheetParse.ts`, que escreve o campo**. Vira fill-in próprio; `priority` fica intocado aqui.
- **Consumidores a jusante:** nível na ordenação do E9 e "meta mínima" N0/N1 no E8 — mudam semântica travada de `goalCoverage` (`meta = expectedVotes[cenário] ?? suggestedGoal`); item separado.
- Sugerir promoções/rebaixamentos automaticamente (E11, padrões K-A/P5); relatório de movimentos (E15); redeployment de brokers (processo humano — relatório K3).
## Rabbit holes
- **Workflow de aprovação multi-etapa.** Vira mini-Jira; o rito é reunião quinzenal + registro, não máquina de estados. **Mitigação:** action única com validações; nada de status "pendente de aprovação".
- **Migrar `priority` destrutivamente agora.** Coluna fica; remoção em migração futura de limpeza pós-estabilização.
- **Calibrar as regras de histerese.** Os cortes (salto de 2 níveis, 3 semanas de proteção, 1 movimento por mês) são os do relatório §6.8 e são **ilustrativos** — calibração é **E15**, num objeto só.
## Adiado com gatilho
- **Notificação de mudança de nível ao assessor do município.** Gatilho: D2 (sino) entregue.
## As-built (2026-07-27)
O que ficou diferente do plano, e o que só se soube implementando:
- **Migration `20260727_161752_add_municipality_engagement_level`** — `engagementLevel` (select indexado, sem default), `levelNote` (textarea 2000) e `levelChangedAt` (date indexada, `readOnly`, escrita por `canSetCampaignSystemField`), mais o valor `movimento` no enum de `allocationDecision.outcome`. O hook `deriveEngagementLevelAudit` carimba `levelChangedAt` só quando o nível muda **e o zera quando o nível é apagado**: uma data de "registrado em" sem nível não descreve nada, e as regras de histerese a leriam como histórico.
- **O `from` do snapshot é verdade sob concorrência** porque a action segura `municipality-engagement-level:{id}` antes de ler o nível atual. Os dois escritos (update do município + `allocationDecision`) vivem na mesma transação, e o snapshot guarda `{ from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt }` — só os valores sobre os quais a decisão foi tomada, nunca o documento.
- **A rota tem três estados, não dois.** `blocked` (409) devolve as violações tipadas em vez de virar "não foi possível salvar": um movimento barrado pela histerese não é uma falha para repetir, é uma decisão que a coordenação pode tomar mesmo assim — então o Popover mantém o rascunho e oferece o override no lugar. As mesmas regras rodam no cliente (o módulo é puro) para o coordenador ver o motivo antes de submeter; o servidor as roda de novo de qualquer jeito.
- **O filtro é armazenado, então entra no `where`** — ao contrário de `classe` (E10), que é derivada e força o caminho `limit: 0`. "Sem nível" não cabe num `in`, então vira um ramo `or` com `{ engagementLevel: { exists: false } }`; selecionar N0 + sem nível (a fila de triagem) continua sendo uma consulta só. **O sort, não.** `n0…n4` ordena lexicograficamente na ordem ordinal, então parecia nativo — mas o Postgres põe NULL **primeiro** no DESC, e sem backfill "N4 primeiro" entregaria uma página inteira de "Sem nível". Ausência de decisão não é o topo da escada: `nivel` entrou em `applyDerivedMunicipalitySort` com `sortByNullableValue`, que joga nulo para o fim nas duas direções (mesma semântica de `sem_base` em `classe`). Custo: sortear por nível carrega o escopo inteiro — o mesmo que o sort padrão (`deficit`) já faz. Int test pina as duas direções.
- **Descoberto no int test:** com `overrideAccess: false`, o Payload **não** rejeita o update inteiro quando o ator não pode escrever o campo — ele descarta o campo em silêncio. O teste do assessor, então, prova que o nível **não mudou**, não que a chamada lançou.
- **Banco de desenvolvimento local estava fora do histórico de migrations:** `allocation_decision.outcome` era `varchar` e o tipo `enum_allocation_decision_outcome` não existia (o `teqo_test`, construído só por migrations, tinha ambos). Reparado à mão no `teqo` (criar o tipo + cast da coluna) antes de aplicar esta migration; nada a fazer em produção, que segue o histórico.
- **Custo:** `/campanha/municipios` fica em 17,7 kB / 322 kB de First Load JS. Suítes: 64 unit (604 testes) e 53 int (427). Nada novo no bundle do cliente: a revisão de performance conferiu contra o build de produção que todo módulo que a ilha importa já estava nos chunks da rota.
- **O que a revisão pós-entrega corrigiu:** (1) um `blocked` vindo do **servidor** — o caso que só existe sob concorrência, quando outra aba moveu o município — virava string no box de erro, sem o checkbox de override ao lado; agora as violações tipadas voltam ao mesmo lugar que as locais, deduplicadas por `id` e presas ao nível para o qual foram levantadas, que é a única forma de o terceiro estado da rota valer para quem mais precisa dele. (2) `formatEngagementLevelLabel` / `EMPTY_ENGAGEMENT_LEVEL_LABEL` mudaram de `municipalityLabels` para `lib/engagementLevel.ts`: importar um valor daquele módulo arrasta o catálogo de 435 entradas e o glossário de conceitos para dentro da ilha (o padrão de 21 kB do B14), hoje inócuo só porque o controle de tendência já mantém a cadeia viva. (3) `isEngagementLevel` saiu de três chamadas onde o tipo gerado pelo Payload já era a união fechada.
- **Débito conhecido (não é do E14 sozinho):** `levelNote` entrou em `municipalityListSelect`, e o sort padrão do staff (`deficit`) roda esse select sobre os 435 municípios do escopo para renderizar 25 linhas — no teto de 2000 caracteres são ~870 KB lidos por request. Hoje custa zero (o campo nasce nulo em todos), e `politicalTrend.note` tem exatamente a mesma forma e já estava lá: o conserto é dos dois juntos (ler as notas só para a fatia da página), não de um.
- **Fora do escopo, confirmado na entrega:** `priority` continua intocado (18 arquivos + o seed `projectionSheetParse.ts` que escreve o campo), e nem o E8 nem o E9 leem o nível.
## Referências
- `docs/roadmap.md` (Inteligência de campanha, E14) · [plano-mestre](inteligencia-campanha.md) (G7)
- `docs/research/relatorio-entrevista-persona-campanha.md` §6.8 (escala, movimento, rito, custo político), K1–K4
- `src/collections/Municipality.ts` (priority/politicalTrend/access), `src/collections/Leadership.ts` (padrão supportStatus)
- `src/utilities/municipalityStaffFormActions.ts`, `src/components/campaign/MunicipalityListTrendControl.tsx` (padrão de controle B9)
- `src/collections/Municipality.ts` (priority/politicalTrend/access), `src/collections/AllocationDecision.ts` (C12)
- `src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts`, `src/components/campaign/municipality/MunicipalityListTrendControl.tsx` (padrão de controle na célula)
- AGENTS.md — field access fail-closed, transações, migrations

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,68 @@
import { NextResponse } from 'next/server'
import { setMunicipalityEngagementLevel } from '@/app/(campaign)/campanha/actions/municipality'
import { EngagementLevelBlockedError } from '@/lib/engagementLevel'
import {
MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE,
municipalityEngagementLevelSchema,
} from '@/lib/schemas/municipality'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import {
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import type { MunicipalityListEngagementLevelResponse } from './types'
export type { MunicipalityListEngagementLevelResponse } from './types'
export const dynamic = 'force-dynamic'
/** JSON body uses `municipalityId`; the action schema uses `municipality`. */
const bodySchema = municipalityEngagementLevelSchema
.omit({ municipality: true })
.extend({ municipalityId: positiveRelationshipId })
export async function POST(
request: Request,
): Promise<NextResponse<MunicipalityListEngagementLevelResponse>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { municipalityId, ...movement } = bodySchema.parse(parsed.body)
const updated = await setMunicipalityEngagementLevel({
municipality: municipalityId,
...movement,
})
return NextResponse.json({
status: 'success',
message: 'Nível de envolvimento registrado.',
savedLevel: {
level: movement.level,
note: updated.levelNote ?? null,
changedAt: updated.levelChangedAt ?? null,
},
})
} catch (error) {
if (error instanceof EngagementLevelBlockedError) {
return NextResponse.json(
{ status: 'blocked', message: error.message, violations: error.violations },
{ status: 409 },
)
}
return campaignJsonMutationErrorResponse(error, {
// Only the unrestricted-actor message: this action reloads the actor with
// its own guard, never through `getFreshStaffActor` (same as `advisors/`).
safeMessages: [MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE],
genericMessage: 'Não foi possível registrar o nível. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -0,0 +1,29 @@
import type { EngagementLevel, EngagementLevelViolation } from '@/lib/engagementLevel'
export type MunicipalityListSavedEngagementLevel = {
level: EngagementLevel
note: string | null
changedAt: string | null
}
/**
* `blocked` is a third state on purpose: a movement held by the stability
* rules is not a failure the coordinator should retry, it is a decision they
* can take anyway — so the reasons travel back and the control offers the
* override in place.
*/
export type MunicipalityListEngagementLevelResponse =
| {
status: 'success'
message: string
savedLevel: MunicipalityListSavedEngagementLevel
}
| {
status: 'blocked'
message: string
violations: EngagementLevelViolation[]
}
| {
status: 'error'
message: string
}

View file

@ -18,6 +18,7 @@ import {
isCampaignCoordinator,
isCampaignLeader,
isCampaignStaff,
isCampaignUnrestricted,
} from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getCampaignScopeLabel } from '@/utilities/municipalityLabels'
@ -48,6 +49,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
if (isCampaignLeader(user)) redirect('/campanha')
const isStaffView = isCampaignStaff(user)
const isCoordinator = isCampaignCoordinator(user)
const canMoveEngagementLevel = isCampaignUnrestricted(user)
const pageBundle = await loadMunicipalityListPageBundle(payload, user, rawSearchParams)
const {
@ -122,6 +124,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
advisorNamesById={advisorNamesById}
isStaffView={isStaffView}
isCoordinator={isCoordinator}
canMoveEngagementLevel={canMoveEngagementLevel}
advisorOptions={advisorOptions}
columnFilterOptions={columnFilterOptions}
signalFormAction={createMunicipalityListSignalFormAction}

View file

@ -2,16 +2,24 @@
import type { Payload } from 'payload'
import {
ENGAGEMENT_LEVEL_PATTERN_ID,
EngagementLevelBlockedError,
getEngagementLevelViolations,
} from '@/lib/engagementLevel'
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
import {
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE,
municipalityAdvisorMembershipSchema,
municipalityAdvisorsAssignmentSchema,
municipalityEngagementLevelSchema,
municipalityExpectedVotesSchema,
municipalityPoliticalTrendSchema,
municipalityStrategyUpdateSchema,
type MunicipalityAdvisorMembershipInput,
type MunicipalityAdvisorsAssignmentInput,
type MunicipalityEngagementLevelInput,
type MunicipalityExpectedVotesInput,
type MunicipalityPoliticalTrendInput,
type MunicipalityStrategyUpdateInput,
@ -126,6 +134,105 @@ export const setMunicipalityExpectedVotes = async (input: MunicipalityExpectedVo
return setMunicipalityExpectedVotesRecord(payload, actor, input)
}
/**
* E14 — moving a município up or down the N0–N4 ladder. Two writes in one
* transaction: the município carries the current level, and `allocationDecision`
* carries the movement forever (C12's first production writer), so the history
* survives the next movement instead of being overwritten by it.
*
* The lock is what makes the recorded "nível anterior" true: without it two
* coordinators moving the same município would each read the same `from` and
* write two decisions claiming to start from it.
*/
export const setMunicipalityEngagementLevelRecord = async (
payload: Payload,
actor: CampaignUser,
input: MunicipalityEngagementLevelInput,
) => {
const { municipality, level, note, reversalSignals, triangulatedShock, override } =
municipalityEngagementLevelSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadUnrestrictedActor(
payload,
actor,
MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE,
req,
)
await acquireTextAdvisoryLocks(payload, req, [
`municipality-engagement-level:${municipality}`,
])
const current = await payload.findByID({
collection: 'municipality',
id: municipality,
depth: 0,
select: { engagementLevel: true, levelChangedAt: true },
user: currentActor,
overrideAccess: false,
req,
})
const from = current.engagementLevel ?? null
const violations = getEngagementLevelViolations({
from,
to: level,
levelChangedAt: current.levelChangedAt ?? null,
now: new Date(),
triangulatedShock,
})
if (violations.length > 0 && !override) throw new EngagementLevelBlockedError(violations)
const updated = await payload.update({
collection: 'municipality',
id: municipality,
data: { engagementLevel: level, levelNote: note },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
await payload.create({
collection: 'allocationDecision',
data: {
municipality,
patternId: ENGAGEMENT_LEVEL_PATTERN_ID,
outcome: 'movimento',
rationale: note,
// Only the values the decision was taken on — no document dumps
// (the collection caps the serialized snapshot at 16 KB).
snapshot: {
from,
to: level,
reversalSignals,
triangulatedShock,
violations: violations.map((violation) => violation.id),
overridden: violations.length > 0,
previousLevelChangedAt: current.levelChangedAt ?? null,
},
},
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
return updated
},
{ beginFailureMessage: 'Não foi possível iniciar o movimento de nível.' },
)
}
export const setMunicipalityEngagementLevel = async (input: MunicipalityEngagementLevelInput) => {
const { payload, actor } = await getCampaignActionContext()
return setMunicipalityEngagementLevelRecord(payload, actor, input)
}
/** Advisor assignment is unrestricted staff (coordinator + candidate); the hook validates eligibility. */
export const assignMunicipalityAdvisorsRecord = async (
payload: Payload,

View file

@ -99,9 +99,13 @@ export const AllocationDecision: CollectionConfig = {
type: 'select',
label: 'Decisão',
required: true,
// `aceita`/`descarta` is the triage of a SUGGESTED pattern (E11). A level
// movement (E14) is neither — it is a decision taken outright — so it
// gets its own value instead of masquerading as an acceptance.
options: [
{ label: 'Aceita', value: 'aceita' },
{ label: 'Descarta', value: 'descarta' },
{ label: 'Movimento', value: 'movimento' },
],
},
{

View file

@ -5,6 +5,11 @@ import type {
} from 'payload'
import { APIError } from 'payload'
import {
ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH,
engagementLevels,
formatEngagementLevelLabel,
} from '@/lib/engagementLevel'
import { politicalTrendStatuses } from '@/lib/schemas/municipality'
import {
getVoteEstimateOrderViolation,
@ -17,6 +22,7 @@ import {
canDeleteMunicipality,
canManageCampaignStaffField,
canManageMunicipalityAdvisors,
canManageMunicipalityEngagementLevel,
canReadCampaignStaffField,
canReadMunicipality,
canSetCampaignSystemField,
@ -106,6 +112,22 @@ const validateMunicipalityAdvisors: CollectionBeforeValidateHook = async ({ data
return data
}
/**
* E14 — `levelChangedAt` is the clock the movement rules read (protection
* window, one movement per month), so it is derived here rather than trusted
* from the caller: an admin editing the level in `/admin` has to start the
* same clock the server action does.
*/
const deriveEngagementLevelAudit: CollectionBeforeChangeHook = ({ data, originalDoc }) => {
if (!data || data.engagementLevel === undefined) return data
if (data.engagementLevel === (originalDoc?.engagementLevel ?? null)) return data
// Clearing the level clears its clock too: "registrado em" with no level is
// a date that describes nothing, and the rules would read it as history.
data.levelChangedAt = data.engagementLevel ? new Date().toISOString() : null
return data
}
const trendSnapshot = (value: unknown) => {
const trend = (value ?? {}) as { status?: unknown; note?: unknown }
return `${trend.status ?? ''}\u0000${typeof trend.note === 'string' ? trend.note.trim() : ''}`
@ -150,7 +172,7 @@ export const Municipality: CollectionConfig = {
},
hooks: {
beforeValidate: [validateMunicipalityAdvisors, validateExpectedVotes],
beforeChange: [derivePoliticalTrendAudit],
beforeChange: [derivePoliticalTrendAudit, deriveEngagementLevelAudit],
},
fields: [
{
@ -302,6 +324,58 @@ export const Municipality: CollectionConfig = {
{ label: 'Normal', value: 'normal' },
],
},
{
name: 'engagementLevel',
type: 'select',
label: 'Nível de envolvimento',
index: true,
// No default on purpose: stamping N2 on all 435 municípios would assert a
// decision nobody took. Absent means "ainda não decidido", and filtering
// for it is the triage queue.
access: {
read: canReadCampaignStaffField,
create: canManageMunicipalityEngagementLevel,
update: canManageMunicipalityEngagementLevel,
},
admin: {
description:
'Quanto a campanha investe neste município (N0 a N4). Movimento é decisão da coordenação e fica registrado em Decisões de alocação.',
},
// Single source: the ladder in `lib/engagementLevel` + the shared label table.
options: engagementLevels.map((value) => ({
label: formatEngagementLevelLabel(value),
value,
})),
},
{
name: 'levelNote',
type: 'textarea',
label: 'Motivo do nível',
maxLength: ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH,
access: {
read: canReadCampaignStaffField,
create: canManageMunicipalityEngagementLevel,
update: canManageMunicipalityEngagementLevel,
},
admin: {
description: 'Motivo corrente do nível — o histórico vive em Decisões de alocação.',
},
},
{
name: 'levelChangedAt',
type: 'date',
label: 'Nível registrado em',
index: true,
admin: {
readOnly: true,
description: 'Derivado da última mudança de nível.',
},
access: {
read: canReadCampaignStaffField,
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'expectedVotes',
type: 'group',

View file

@ -190,6 +190,18 @@ export const MunicipalityFilters = ({
}
/>
) : null}
{showStaffFilters ? (
<CampaignMobileMultiFilterField
id="municipality-filter-level"
label="Nível"
emptyLabel="Todos"
options={getMunicipalityFilterDefinition('level').options ?? []}
selected={state.levels ?? []}
onToggle={(value) =>
navigateWithSearch(toggleMunicipalityMultiFilterValue(state, 'level', value))
}
/>
) : null}
{showStaffFilters && advisorFilterOptions.length ? (
<CampaignMobileMultiFilterField
id="municipality-filter-advisor"

View file

@ -47,6 +47,7 @@ const multiParamByFilter = {
advisor: 'advisor',
trend: 'trend',
class: 'class',
level: 'level',
kind: null,
coverage: null,
} as const satisfies Record<MunicipalityFilterParam, MunicipalityMultiFilterParam | null>

View file

@ -0,0 +1,67 @@
import { Badge } from '@/components/ui/Badge'
import {
EMPTY_ENGAGEMENT_LEVEL_LABEL,
formatEngagementLevelLabel,
type EngagementLevel,
} from '@/lib/engagementLevel'
/**
* Deliberately NOT a colour ramp. A five-step scale of importance painted
* across a staff list is the "mapa de onde você não vai defender" the research
* warns about (anti-goal 11), and this row already spends red on priority,
* amber on cold signals and five fills on the E10 class. The ordinal
* information is in the numeral; the pill is a hairline. Absence of a level is
* not a sixth step, so it reads as flat muted text instead.
*/
const ENGAGEMENT_LEVEL_BADGE_VARIANT = 'outline' as const
const EMPTY_ENGAGEMENT_LEVEL_BADGE_VARIANT = 'ghost' as const
type MunicipalityLevelBadgeProps = {
level: EngagementLevel | null
/** The rationale behind the current level, when the caller has it. */
note?: string | null
/**
* In the table the numeral carries it and the long name lives in the
* column's `cellTooltip`; elsewhere there is room to spell it out.
*/
layout: 'table' | 'card'
}
/**
* E14 — the read-only face of the ladder, for staff who cannot move it.
*
* The long name (and the motivo, when given) is always TEXT next to the pill —
* `sr-only` in the table, visible in the card — because the B23 `cellTooltip`
* contract is that the tooltip only repeats what the cell already says: Radix
* mounts tooltip content on open, and the table adds no tab stops. Same shape
* as `TerritorialClassReadout` in the neighbouring column.
*/
export const MunicipalityLevelBadge = ({ level, note, layout }: MunicipalityLevelBadgeProps) => {
if (!level) {
return (
<Badge variant={EMPTY_ENGAGEMENT_LEVEL_BADGE_VARIANT} className="text-muted-foreground">
{EMPTY_ENGAGEMENT_LEVEL_LABEL}
</Badge>
)
}
const label = formatEngagementLevelLabel(level)
// The card spells the name out in the pill itself, so only the motivo is
// left to say; the table shows the numeral and owes the reader both.
if (layout === 'card') {
return (
<>
<Badge variant={ENGAGEMENT_LEVEL_BADGE_VARIANT}>{label}</Badge>
{note ? <span className="text-xs text-muted-foreground">{note}</span> : null}
</>
)
}
return (
<>
<Badge variant={ENGAGEMENT_LEVEL_BADGE_VARIANT}>{level.toUpperCase()}</Badge>
<span className="sr-only">{[label, note].filter(Boolean).join(' — ')}</span>
</>
)
}

View file

@ -7,9 +7,11 @@ import {
MissingAdvisorBadge,
MunicipalityAdvisorAvatarStack,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalitySortableHead } from '@/components/campaign/municipality/MunicipalitySortableHead'
@ -32,6 +34,7 @@ import {
formatPlacementOrdinal,
formatVoteSharePercent,
} from '@/lib/electionFormat'
import { formatEngagementLevelLabel } from '@/lib/engagementLevel'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
@ -85,6 +88,12 @@ export type MunicipalityListProps = {
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
isStaffView: boolean
isCoordinator: boolean
/**
* E14 — moving the ladder is unrestricted staff (coordinator + candidate),
* a wider set than `isCoordinator`, so it travels as its own prop instead of
* quietly hiding the control from the candidate.
*/
canMoveEngagementLevel: boolean
advisorOptions: EligibleAdvisorOption[]
columnFilterOptions: MunicipalityColumnFilterOptions
signalFormAction: MunicipalityStaffFormAction
@ -239,6 +248,7 @@ const municipalityListColumns = ({
state,
isStaffView,
isCoordinator,
canMoveEngagementLevel,
columnFilterOptions,
advisorNamesById,
advisorOptions,
@ -347,6 +357,44 @@ const municipalityListColumns = ({
? null
: formatTerritorialClassWhy(municipality.territorialClassFactors),
},
{
// Right after "Classe": the pair reads diagnosis then decision.
id: 'level',
head: (
<MunicipalitySortableHead
state={state}
sortKey="nivel"
filterParam="level"
description={municipalityColumnDescriptions.nivel}
>
Nível
</MunicipalitySortableHead>
),
cell: (municipality) =>
canMoveEngagementLevel ? (
<MunicipalityListLevelControl
municipalityID={municipality.id}
municipalityName={municipality.name}
level={municipality.engagementLevel}
levelNote={municipality.levelNote}
levelChangedAt={municipality.levelChangedAt}
/>
) : (
<MunicipalityLevelBadge
level={municipality.engagementLevel}
note={municipality.levelNote}
layout="table"
/>
),
// The editable cell wraps its own trigger in a tooltip; declaring it
// here too would double it up on the same gesture (as in `advisors`).
cellTooltip: (municipality) =>
canMoveEngagementLevel || !municipality.engagementLevel
? null
: [formatEngagementLevelLabel(municipality.engagementLevel), municipality.levelNote]
.filter(Boolean)
.join(' — '),
},
{
id: 'advisors',
head: (
@ -595,6 +643,26 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
/>
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Nível</dt>
<dd>
{props.canMoveEngagementLevel ? (
<MunicipalityListLevelControl
municipalityID={municipality.id}
municipalityName={municipality.name}
level={municipality.engagementLevel}
levelNote={municipality.levelNote}
levelChangedAt={municipality.levelChangedAt}
/>
) : (
<MunicipalityLevelBadge
level={municipality.engagementLevel}
note={municipality.levelNote}
layout="card"
/>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Tendência</dt>
<dd>

View file

@ -0,0 +1,332 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type {
MunicipalityListEngagementLevelResponse,
MunicipalityListSavedEngagementLevel,
} from '@/app/(campaign)/campanha/(app)/municipios/engagement-level/types'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import {
EMPTY_ENGAGEMENT_LEVEL_LABEL,
ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH,
engagementLevelRank,
engagementLevels,
formatEngagementLevelLabel,
getEngagementLevelViolations,
isEngagementLevel,
type EngagementLevel,
type EngagementLevelViolation,
} from '@/lib/engagementLevel'
import { cn } from '@/lib/utils'
const ENGAGEMENT_LEVEL_ENDPOINT = '/campanha/municipios/engagement-level'
const SAVE_ERROR_MESSAGE = 'Não foi possível registrar o nível. Tente novamente.'
type MunicipalityListLevelControlProps = {
municipalityID: number
municipalityName: string
level: EngagementLevel | null
levelNote: string | null
levelChangedAt: string | null
}
/**
* E14 — unlike the trend and estimate popovers next to it, this one submits
* explicitly: a movement carries a motivo AND the signals that would reverse
* it, and it may need an override. Auto-saving half of that would file an
* incomplete decision under the coordinator's name.
*/
export const MunicipalityListLevelControl = ({
municipalityID,
municipalityName,
level,
levelNote,
levelChangedAt,
}: MunicipalityListLevelControlProps) => {
const [open, setOpen] = useState(false)
const [saved, setSaved] = useState<MunicipalityListSavedEngagementLevel | null>(
level ? { level, note: levelNote, changedAt: levelChangedAt } : null,
)
// Seeded by `resetDraft` on every open; while closed the popover body — and
// with it the select — is not mounted.
const [draftLevel, setDraftLevel] = useState<EngagementLevel | ''>('')
const [note, setNote] = useState('')
const [reversalSignals, setReversalSignals] = useState('')
const [triangulatedShock, setTriangulatedShock] = useState(false)
const [override, setOverride] = useState(false)
const [isPending, setIsPending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// Violations only the server could know (it re-reads the município under the
// lock), kept with the level they were raised for so a changed draft drops them.
const [serverBlock, setServerBlock] = useState<{
level: EngagementLevel
violations: EngagementLevelViolation[]
} | null>(null)
const abortRef = useRef<AbortController | null>(null)
const lastPropsRef = useRef({ level, levelNote, levelChangedAt })
// Adopt the server value when it changes from outside (navigation / refresh).
useEffect(() => {
const last = lastPropsRef.current
if (
last.level === level &&
last.levelNote === levelNote &&
last.levelChangedAt === levelChangedAt
)
return
lastPropsRef.current = { level, levelNote, levelChangedAt }
setSaved(level ? { level, note: levelNote, changedAt: levelChangedAt } : null)
}, [level, levelNote, levelChangedAt])
useEffect(() => () => abortRef.current?.abort(), [])
const currentLevel = saved?.level ?? null
const currentNote = saved?.note ?? null
const isMovement = draftLevel !== '' && draftLevel !== currentLevel
// The rules are pure and client-safe, so the coordinator sees why a movement
// is held before submitting it — the server re-runs them either way, and what
// it raises on top (a concurrent move this tab has not seen) joins the list
// rather than turning into an error string with no override next to it.
const localViolations = isMovement
? getEngagementLevelViolations({
from: currentLevel,
to: draftLevel,
levelChangedAt: saved?.changedAt ?? null,
now: new Date(),
triangulatedShock,
})
: []
const violations =
serverBlock && serverBlock.level === draftLevel
? [
...localViolations,
...serverBlock.violations.filter(
(violation) => !localViolations.some((local) => local.id === violation.id),
),
]
: localViolations
const isJump =
isMovement &&
currentLevel !== null &&
Math.abs(engagementLevelRank[draftLevel] - engagementLevelRank[currentLevel]) > 1
const resetDraft = () => {
setDraftLevel(currentLevel ?? '')
setNote('')
setReversalSignals('')
setTriangulatedShock(false)
setOverride(false)
setErrorMessage(null)
setServerBlock(null)
}
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) resetDraft()
setOpen(nextOpen)
}
const submit = async () => {
if (!isMovement || isPending) return
const controller = new AbortController()
abortRef.current = controller
setIsPending(true)
setErrorMessage(null)
setServerBlock(null)
try {
const response = await fetch(ENGAGEMENT_LEVEL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
signal: controller.signal,
body: JSON.stringify({
municipalityId: municipalityID,
level: draftLevel,
note,
reversalSignals,
triangulatedShock,
override,
}),
})
const payload = (await response.json()) as MunicipalityListEngagementLevelResponse
if (payload.status === 'success') {
setSaved(payload.savedLevel)
setOpen(false)
return
}
// A blocked movement keeps the popover open with the draft intact: the
// next click is "override", not "type it all again".
if (payload.status === 'blocked') {
setServerBlock({ level: draftLevel, violations: payload.violations })
return
}
setErrorMessage(payload.message)
} catch {
setErrorMessage(SAVE_ERROR_MESSAGE)
} finally {
setIsPending(false)
}
}
const canSubmit = isMovement && note.trim().length > 0 && reversalSignals.trim().length > 0
const fieldId = (suffix: string) => `municipality-list-level-${suffix}-${municipalityID}`
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<CampaignHoverTooltip
content={
currentLevel ? (
<div className="space-y-1">
<p>{formatEngagementLevelLabel(currentLevel)}</p>
{currentNote ? <p className="whitespace-pre-wrap">{currentNote}</p> : null}
</div>
) : null
}
align="start"
openOnTouch={false}
disabled={open}
>
<PopoverTrigger asChild>
<button
type="button"
aria-expanded={open}
aria-haspopup="dialog"
className={cn(
'min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
open ? 'bg-muted/60' : undefined,
)}
aria-label={`Nível de envolvimento de ${municipalityName}: ${
currentLevel ? formatEngagementLevelLabel(currentLevel) : EMPTY_ENGAGEMENT_LEVEL_LABEL
}`}
>
<MunicipalityLevelBadge level={currentLevel} layout="table" />
</button>
</PopoverTrigger>
</CampaignHoverTooltip>
<PopoverContent
align="start"
sideOffset={8}
className="w-80 p-3"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={fieldId('select')}>Nível de envolvimento</FieldLabel>
<NativeSelect
id={fieldId('select')}
value={draftLevel}
onChange={(event) =>
setDraftLevel(isEngagementLevel(event.target.value) ? event.target.value : '')
}
className="min-h-11 w-full"
>
<NativeSelectOption value="">{EMPTY_ENGAGEMENT_LEVEL_LABEL}</NativeSelectOption>
{engagementLevels.map((option) => (
<NativeSelectOption key={option} value={option}>
{formatEngagementLevelLabel(option)}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor={fieldId('note')}>Motivo</FieldLabel>
<Textarea
id={fieldId('note')}
value={note}
onChange={(event) => setNote(event.target.value)}
maxLength={ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH}
rows={2}
className="min-h-16 resize-y"
placeholder="O que mudou para justificar este nível?"
/>
</Field>
<Field>
<FieldLabel htmlFor={fieldId('reversal')}>O que faria voltar atrás</FieldLabel>
<Textarea
id={fieldId('reversal')}
value={reversalSignals}
onChange={(event) => setReversalSignals(event.target.value)}
maxLength={ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH}
rows={2}
className="min-h-16 resize-y"
placeholder="Sinais que fariam rever esta decisão."
/>
</Field>
{isJump ? (
<Field orientation="horizontal">
<Checkbox
id={fieldId('shock')}
checked={triangulatedShock}
onCheckedChange={(checked) => setTriangulatedShock(checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={fieldId('shock')}>
Choque triangulado (dois níveis de uma vez)
</FieldLabel>
</FieldContent>
</Field>
) : null}
{violations.length > 0 ? (
<>
<Alert className="py-2">
<AlertDescription className="text-xs">
<ul className="list-disc space-y-1 pl-4">
{violations.map((violation) => (
<li key={violation.id}>{violation.message}</li>
))}
</ul>
</AlertDescription>
</Alert>
<Field orientation="horizontal">
<Checkbox
id={fieldId('override')}
checked={override}
onCheckedChange={(checked) => setOverride(checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={fieldId('override')}>
Registrar mesmo assim, ciente das ressalvas
</FieldLabel>
</FieldContent>
</Field>
</>
) : null}
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<Button
type="button"
size="sm"
disabled={!canSubmit || isPending || (violations.length > 0 && !override)}
onClick={() => void submit()}
>
{isPending ? <Spinner className="size-3.5" aria-hidden /> : null}
Registrar movimento
</Button>
<p className="sr-only" aria-live="polite">
{isPending ? 'Registrando nível.' : (errorMessage ?? '')}
</p>
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -1,6 +1,7 @@
import { PencilIcon } from 'lucide-react'
import Link from 'next/link'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyChips'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
@ -47,6 +48,7 @@ export const MunicipalityStrategyCard = ({
) : (
<Badge variant="outline">Tendência não registrada</Badge>
)}
<MunicipalityLevelBadge level={strategy.engagementLevel} layout="card" />
</div>
</div>
{canEdit ? (
@ -59,6 +61,22 @@ export const MunicipalityStrategyCard = ({
) : null}
</div>
{strategy.engagementLevel && (strategy.levelNote || strategy.levelChangedAt) ? (
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">Nível de envolvimento</h3>
{strategy.levelNote ? (
<p className="text-sm whitespace-pre-wrap text-muted-foreground">
{strategy.levelNote}
</p>
) : null}
{strategy.levelChangedAt ? (
<p className="text-xs text-muted-foreground">
Registrado em {dateFormatter.format(new Date(strategy.levelChangedAt))}
</p>
) : null}
</div>
) : null}
{trend.status || trend.note ? (
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">Tendência política</h3>

View file

@ -25,6 +25,7 @@ export type CampaignConceptId =
| 'roll-off'
| 'dominancia-relativa'
| 'classe-territorial'
| 'nivel-de-envolvimento'
| 'quantis-do-mapa'
| 'posicao-no-municipio'
| 'meta'
@ -184,6 +185,21 @@ export const campaignIntelligenceConcepts: ReadonlyArray<CampaignIntelligenceCon
whereItAppears:
'Card "Conta da cadeira" e capa do dossiê, no detalhe do município, coluna "Classe" da lista de municípios (com filtro e ordenação), e coluna "Classe" em `/campanha/territorios`.',
},
{
id: 'nivel-de-envolvimento',
categoryID: 'diagnostico',
title: 'Nível de envolvimento (N0–N4)',
oneLiner:
'Quanto a campanha decidiu investir neste município: N0 monitorar, N1 presença de mandato, N2 rede sem agenda, N3 rede com agenda, N4 investimento pleno.',
formula:
'Não é calculado: é declarado pela coordenação geral. Todo movimento exige motivo e os sinais que o fariam ser revertido, e fica registrado em Decisões de alocação com o nível anterior, o novo e a data. Três regras seguram a oscilação: pular dois níveis de uma vez só com choque triangulado, não rebaixar um nível decidido há menos de três semanas, e um movimento por mês. A coordenação pode passar por cima delas — o override é gravado com as regras que contrariou.',
example:
'Um município em N1 que recebeu rede e entrou na agenda de giro sobe para N3; nas três semanas seguintes um pedido de rebaixamento fica bloqueado até a janela de proteção fechar, salvo override justificado.',
whyItMatters:
'Separa o que a campanha SABE sobre o município (classe, captura, cobertura da meta) do que a campanha DECIDIU fazer nele. Sem esse registro, a alocação de presença vira memória de reunião: ninguém sabe quem prometeu o quê, nem por quê, nem o que faria voltar atrás. Os cortes das regras de estabilidade são ilustrativos até o backtest calibrá-los.',
whereItAppears:
'Coluna "Nível" da lista de municípios (com filtro, ordenação e edição pela coordenação) e bloco de estratégia no detalhe do município.',
},
{
id: 'elegibilidade-para-visita',
categoryID: 'diagnostico',

173
src/lib/engagementLevel.ts Normal file
View file

@ -0,0 +1,173 @@
/**
* E14 — the N0–N4 engagement ladder and the movement rules that guard it.
* Pure and client-safe: the collection, the server action, the list URL and
* the Popover control all read the ladder from here, and the rules are unit
* tested without a database.
*
* The cuts below are the ones the research report (§6.8) proposes and are
* ILLUSTRATIVE — calibrating them against real movement is E15. They live in
* one object so recalibration is a one-line diff.
*/
export const engagementLevels = ['n0', 'n1', 'n2', 'n3', 'n4'] as const
/** `allocationDecision.patternId` under which every level movement is filed. */
export const ENGAGEMENT_LEVEL_PATTERN_ID = 'nivel'
/** Cap shared by the field, the schema and the textareas, so they agree. */
export const ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH = 2000
export type EngagementLevel = (typeof engagementLevels)[number]
/**
* Ordinal position on the ladder, for measuring the DISTANCE of a movement —
* ordering is Postgres's job (the column sorts lexicographically in this same
* order). A table rather than `engagementLevels.indexOf` so the lookup is total
* and cannot answer -1.
*/
export const engagementLevelRank: Record<EngagementLevel, number> = {
n0: 0,
n1: 1,
n2: 2,
n3: 3,
n4: 4,
}
export const isEngagementLevel = (value: unknown): value is EngagementLevel =>
typeof value === 'string' && engagementLevels.includes(value as EngagementLevel)
/** The ladder as the mesa says it out loud (research §6.8). */
const engagementLevelNames: Record<EngagementLevel, string> = {
n0: 'Monitorar',
n1: 'Presença de mandato',
n2: 'Rede sem agenda',
n3: 'Rede + agenda',
n4: 'Investimento pleno',
}
/**
* "N3 · Rede + agenda" — the numeral alone is the badge, this is the tooltip.
* Here rather than in `municipalityLabels` so the Popover island can name a
* level without importing a module that builds the 435-entry catalog and the
* concept glossary at init.
*/
export const formatEngagementLevelLabel = (level: EngagementLevel): string =>
`${level.toUpperCase()} · ${engagementLevelNames[level]}`
export const EMPTY_ENGAGEMENT_LEVEL_LABEL = 'Sem nível'
/**
* Hysteresis, so the ladder records decisions instead of oscillating with the
* mood of the week (research §6.8).
*/
export const ENGAGEMENT_LEVEL_RULES = {
/**
* A freshly decided level is protected from being undone for this long.
* §6.8 words this as protecting a PROMOTION; guarding any recent decision is
* both stricter and derivable from `levelChangedAt` alone — knowing the
* direction of the last movement would cost a query the control cannot make.
* The override covers the difference; E15 calibrates.
*/
protectionWindowDays: 21,
/** Beyond this distance a single movement needs a triangulated shock. */
maxStepsWithoutShock: 1,
} as const
export type EngagementLevelViolationId =
| 'salto-de-dois-niveis'
| 'janela-de-protecao'
| 'dois-movimentos-no-mes'
export type EngagementLevelViolation = {
id: EngagementLevelViolationId
/** Rendered to the coordinator as the reason the movement is being held. */
message: string
}
export type EngagementLevelMovementInput = {
from: EngagementLevel | null
to: EngagementLevel
/** When the current level was recorded; null when the município never had one. */
levelChangedAt: string | null
now: Date
/** The coordinator declared a triangulated shock, which licenses a 2-level jump. */
triangulatedShock: boolean
}
const DAY_MS = 24 * 60 * 60 * 1000
const daysBetween = (from: Date, to: Date): number => (to.getTime() - from.getTime()) / DAY_MS
const isSameCalendarMonth = (left: Date, right: Date): boolean =>
left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth()
/**
* The movement rules, as a list rather than a boolean: the control shows the
* coordinator every reason at once, and the accepted override is stored with
* exactly the reasons it overrode.
*
* "Two movements in a month" needs no history query — the movement being made
* is at least the second one of the month precisely when the previous one
* landed in the same calendar month.
*/
export const getEngagementLevelViolations = ({
from,
to,
levelChangedAt,
now,
triangulatedShock,
}: EngagementLevelMovementInput): EngagementLevelViolation[] => {
// The first level a município ever gets is a starting point, not a movement:
// there is nothing to oscillate against yet.
if (from === null || from === to) return []
const violations: EngagementLevelViolation[] = []
const steps = Math.abs(engagementLevelRank[to] - engagementLevelRank[from])
const isPromotion = engagementLevelRank[to] > engagementLevelRank[from]
if (steps > ENGAGEMENT_LEVEL_RULES.maxStepsWithoutShock && !triangulatedShock) {
violations.push({
id: 'salto-de-dois-niveis',
message: `Movimento de ${steps} níveis de uma vez. Só com choque triangulado.`,
})
}
const changedAt = levelChangedAt ? new Date(levelChangedAt) : null
const hasChangedAt = changedAt !== null && !Number.isNaN(changedAt.getTime())
if (hasChangedAt) {
const daysSinceChange = daysBetween(changedAt, now)
if (!isPromotion && daysSinceChange < ENGAGEMENT_LEVEL_RULES.protectionWindowDays) {
violations.push({
id: 'janela-de-protecao',
message: `O nível atual tem menos de ${ENGAGEMENT_LEVEL_RULES.protectionWindowDays} dias. Rebaixar agora desfaz uma decisão dentro da janela de proteção.`,
})
}
if (isSameCalendarMonth(changedAt, now)) {
violations.push({
id: 'dois-movimentos-no-mes',
message: 'Este município já mudou de nível neste mês.',
})
}
}
return violations
}
/**
* Thrown by the server action when a movement breaks a rule and the
* coordinator has not overridden it. It carries the reasons so the route can
* answer with them and the control can offer the override in place, instead of
* collapsing everything into one opaque "não foi possível salvar".
*/
export class EngagementLevelBlockedError extends Error {
readonly violations: EngagementLevelViolation[]
constructor(violations: EngagementLevelViolation[]) {
super('O movimento contraria as regras de estabilidade do nível.')
this.name = 'EngagementLevelBlockedError'
this.violations = violations
}
}

View file

@ -1,5 +1,6 @@
import { z } from 'zod'
import { ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH, engagementLevels } from '@/lib/engagementLevel'
import { positiveRelationshipId, trimmedNullableText } from '@/lib/schemas/primitives'
import { voteEstimateScenarioFieldsSchema } from '@/lib/schemas/votePledge'
@ -62,6 +63,25 @@ export const MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES = [
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
] as const
export const MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE =
'Somente a coordenação geral ou o candidato move o nível de envolvimento.'
/**
* E14 — a movement is never just the new level: the motivo and the signals
* that would reverse it are what make the decision auditable later, so both
* are required by the schema rather than by the form alone.
*/
export const municipalityEngagementLevelSchema = z.object({
municipality: positiveRelationshipId,
level: z.enum(engagementLevels),
note: z.string().trim().min(1).max(ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH),
reversalSignals: z.string().trim().min(1).max(ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH),
/** Licenses a two-level jump (research §6.8). */
triangulatedShock: z.boolean().default(false),
/** Accepts the listed violations knowingly; recorded in the decision snapshot. */
override: z.boolean().default(false),
})
export const municipalityExpectedVotesSchema = z.object({
municipality: positiveRelationshipId,
expectedVotes: voteEstimateScenarioFieldsSchema,
@ -74,3 +94,4 @@ export type MunicipalityAdvisorsAssignmentInput = z.input<
>
export type MunicipalityAdvisorMembershipInput = z.input<typeof municipalityAdvisorMembershipSchema>
export type MunicipalityExpectedVotesInput = z.input<typeof municipalityExpectedVotesSchema>
export type MunicipalityEngagementLevelInput = z.input<typeof municipalityEngagementLevelSchema>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_municipality_engagement_level" AS ENUM('n0', 'n1', 'n2', 'n3', 'n4');
ALTER TYPE "public"."enum_allocation_decision_outcome" ADD VALUE 'movimento';
ALTER TABLE "municipality" ADD COLUMN "engagement_level" "enum_municipality_engagement_level";
ALTER TABLE "municipality" ADD COLUMN "level_note" varchar;
ALTER TABLE "municipality" ADD COLUMN "level_changed_at" timestamp(3) with time zone;
CREATE INDEX "municipality_engagement_level_idx" ON "municipality" USING btree ("engagement_level");
CREATE INDEX "municipality_level_changed_at_idx" ON "municipality" USING btree ("level_changed_at");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "allocation_decision" ALTER COLUMN "outcome" SET DATA TYPE text;
DROP TYPE "public"."enum_allocation_decision_outcome";
CREATE TYPE "public"."enum_allocation_decision_outcome" AS ENUM('aceita', 'descarta');
ALTER TABLE "allocation_decision" ALTER COLUMN "outcome" SET DATA TYPE "public"."enum_allocation_decision_outcome" USING "outcome"::"public"."enum_allocation_decision_outcome";
DROP INDEX "municipality_engagement_level_idx";
DROP INDEX "municipality_level_changed_at_idx";
ALTER TABLE "municipality" DROP COLUMN "engagement_level";
ALTER TABLE "municipality" DROP COLUMN "level_note";
ALTER TABLE "municipality" DROP COLUMN "level_changed_at";
DROP TYPE "public"."enum_municipality_engagement_level";`)
}

View file

@ -31,6 +31,7 @@ import * as migration_20260725_022155_add_municipality_budget_notes from './2026
import * as migration_20260725_170000_whatsapp_subscription_consent_key from './20260725_170000_whatsapp_subscription_consent_key';
import * as migration_20260725_213000_rename_action_plan_to_activity from './20260725_213000_rename_action_plan_to_activity';
import * as migration_20260727_032523_add_users_roles from './20260727_032523_add_users_roles';
import * as migration_20260727_161752_add_municipality_engagement_level from './20260727_161752_add_municipality_engagement_level';
export const migrations = [
{
@ -198,4 +199,9 @@ export const migrations = [
down: migration_20260727_032523_add_users_roles.down,
name: '20260727_032523_add_users_roles',
},
{
up: migration_20260727_161752_add_municipality_engagement_level.up,
down: migration_20260727_161752_add_municipality_engagement_level.down,
name: '20260727_161752_add_municipality_engagement_level'
},
];

View file

@ -417,6 +417,18 @@ export interface Municipality {
zoneNumber?: number | null;
advisors?: (number | CampaignUser)[] | null;
priority?: ('alta' | 'normal') | null;
/**
* Quanto a campanha investe neste município (N0 a N4). Movimento é decisão da coordenação e fica registrado em Decisões de alocação.
*/
engagementLevel?: ('n0' | 'n1' | 'n2' | 'n3' | 'n4') | null;
/**
* Motivo corrente do nível — o histórico vive em Decisões de alocação.
*/
levelNote?: string | null;
/**
* Derivado da última mudança de nível.
*/
levelChangedAt?: string | null;
/**
* Total esperado do município por cenário (pessimista/média/otimista) — distinto da soma das lideranças.
*/
@ -560,7 +572,7 @@ export interface AllocationDecision {
id: number;
municipality: number | Municipality;
patternId: string;
outcome: 'aceita' | 'descarta';
outcome: 'aceita' | 'descarta' | 'movimento';
rationale: string;
alternativeReading?: string | null;
/**
@ -1389,6 +1401,9 @@ export interface MunicipalitySelect<T extends boolean = true> {
zoneNumber?: T;
advisors?: T;
priority?: T;
engagementLevel?: T;
levelNote?: T;
levelChangedAt?: T;
expectedVotes?:
| T
| {

View file

@ -217,11 +217,27 @@ export const canUpdateMunicipality: Access = async ({ req }) => {
export const canDeleteMunicipality: Access = ({ req }) => isPayloadAdmin(req.user)
/** Advisor assignment is unrestricted staff (coordinator + candidate; actions use overrideAccess). */
export const canAssignMunicipalityAdvisors: FieldAccess = async ({ req }) => {
/**
* Fields only the coordination itself may write. The two policies below are
* named apart because they answer different questions and can diverge, but
* they resolve "unrestricted staff" once so a change to what that means is
* made in one place.
*/
const unrestrictedCampaignFieldAccess: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignUnrestricted(await getFreshCampaignUser(req))
}
/** Advisor assignment is unrestricted staff (coordinator + candidate; actions use overrideAccess). */
export const canAssignMunicipalityAdvisors = unrestrictedCampaignFieldAccess
export const canManageMunicipalityAdvisors: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
/**
* E14 — moving a município between engagement levels is a reallocation
* decision, so it is unrestricted staff only; the advisor proposes it through
* a signal. Reading stays with `canReadCampaignStaffField` like the rest of
* the staff block.
*/
export const canManageMunicipalityEngagementLevel = unrestrictedCampaignFieldAccess

View file

@ -37,6 +37,7 @@ export {
canCreateMunicipality,
canDeleteMunicipality,
canManageMunicipalityAdvisors,
canManageMunicipalityEngagementLevel,
canReadMunicipality,
canUpdateMunicipality,
getAccessibleMunicipalityIds,

View file

@ -186,7 +186,7 @@ export const formatMunicipalityGeographyLabel = (municipality: {
}
/**
* The 11 real column ids in `/campanha/municipios` — 10 staff columns plus
* The 12 real column ids in `/campanha/municipios` — 11 staff columns plus
* `lastUpdateAt`, which only the non-staff (leader) view renders. Closed union
* so `municipalityColumnDescriptions` below can't silently drop a column.
*/
@ -196,6 +196,7 @@ export type MunicipalityListColumnId =
| 'kind'
| 'votos'
| 'classe'
| 'nivel'
| 'advisors'
| 'trend'
| 'expectedVotes'
@ -225,6 +226,7 @@ export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, st
kind: 'Município inteiro ou zona eleitoral de Salvador (ZE 1–19).',
votos: formatMunicipalityConcentrationHint(),
classe: CLASS_COLUMN_DESCRIPTION,
nivel: campaignConceptOneLiner('nivel-de-envolvimento'),
advisors: 'Assessor(es) responsável(is) pelo município — o coordenador atribui clicando aqui.',
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
expectedVotes: campaignConceptOneLiner('meta'),

View file

@ -4,6 +4,7 @@
* the former `municipalityUi.ts` in Pass 2 W1.
*/
import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { EMPTY_ENGAGEMENT_LEVEL_LABEL, formatEngagementLevelLabel } from '@/lib/engagementLevel'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
import {
@ -16,9 +17,12 @@ import {
} from '@/utilities/municipalityLabels'
import {
buildMunicipalityListHref,
municipalityListLevelFilterValues,
municipalityListStateToRawParams,
NO_LEVEL_FILTER_VALUE,
parseMunicipalityListParams,
serializeCanonicalMunicipalityListSearchParams,
type MunicipalityListLevelFilterValue,
type MunicipalityListState,
} from '@/utilities/municipalityListUrl'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
@ -31,6 +35,7 @@ export type MunicipalityFilterParam =
| 'coverage'
| 'trend'
| 'class'
| 'level'
| 'advisor'
type MunicipalityFilterSelectionMode = 'single' | 'multi' | 'toggle'
@ -118,6 +123,22 @@ export const municipalityFilterDefinitions: MunicipalityFilterDefinition[] = [
label: territorialClassLabels[territorialClass],
})),
},
{
param: 'level',
label: 'Nível',
staffOnly: true,
selection: 'multi',
// Static options, like `trend`: the ladder is a closed set, so offering
// only the levels currently in use would hide the empty rungs the mesa is
// deciding to fill. "Sem nível" is an option because it is the triage queue.
options: municipalityListLevelFilterValues.map((value) => ({
value,
label:
value === NO_LEVEL_FILTER_VALUE
? EMPTY_ENGAGEMENT_LEVEL_LABEL
: formatEngagementLevelLabel(value),
})),
},
]
const municipalityFilterDefinitionByParam = Object.fromEntries(
@ -133,7 +154,13 @@ export const getMunicipalitySingleFilterValue = (
param: 'kind' | 'coverage',
): string | undefined => state[param]
export type MunicipalityMultiFilterParam = 'region' | 'slug' | 'advisor' | 'trend' | 'class'
export type MunicipalityMultiFilterParam =
| 'region'
| 'slug'
| 'advisor'
| 'trend'
| 'class'
| 'level'
export const getMunicipalityMultiFilterValues = (
state: MunicipalityListState,
@ -143,6 +170,7 @@ export const getMunicipalityMultiFilterValues = (
if (param === 'slug') return state.slugs ?? []
if (param === 'trend') return state.trends ?? []
if (param === 'class') return state.classes ?? []
if (param === 'level') return state.levels ?? []
return (state.advisors ?? []).map(String)
}
@ -241,6 +269,11 @@ const territorialClassCount = Object.keys(territorialClassLabels).length
const isTerritorialClass = (value: string): value is MunicipalityTerritorialClass =>
value in territorialClassLabels
const engagementLevelFilterCount = municipalityListLevelFilterValues.length
const isEngagementLevelFilterValue = (value: string): value is MunicipalityListLevelFilterValue =>
(municipalityListLevelFilterValues as readonly string[]).includes(value)
/**
* B16+ href fast path: option rows only need the WOULD-BE href, so the toggled
* state is derived with the same canonical rules the parser applies (advisors
@ -282,6 +315,10 @@ export const buildMunicipalityFilterOptionHref = (
const classes = toggled.filter(isTerritorialClass)
if (classes.length && classes.length < territorialClassCount) next.classes = classes
else delete next.classes
} else if (param === 'level') {
const levels = toggled.filter(isEngagementLevelFilterValue)
if (levels.length && levels.length < engagementLevelFilterCount) next.levels = levels
else delete next.levels
}
const query = serializeCanonicalMunicipalityListSearchParams(next).toString()
@ -308,6 +345,8 @@ export const isMunicipalityColumnFilterActive = (
return Boolean(state.trends?.length)
case 'class':
return Boolean(state.classes?.length)
case 'level':
return Boolean(state.levels?.length)
}
}
@ -339,6 +378,17 @@ export const formatMunicipalityActiveFiltersSummary = (
`Classe ${state.classes.map((territorialClass) => territorialClassLabels[territorialClass].toLowerCase()).join(', ')}`,
)
}
if (state.levels?.length) {
parts.push(
`Nível ${state.levels
.map((level) =>
level === NO_LEVEL_FILTER_VALUE
? EMPTY_ENGAGEMENT_LEVEL_LABEL.toLowerCase()
: level.toUpperCase(),
)
.join(', ')}`,
)
}
if (state.q) parts.push(`Busca "${state.q}"`)
return parts.length ? parts.join(' · ') : null

View file

@ -7,6 +7,7 @@
import type { Where } from 'payload'
import { bahiaIdentityTerritories, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { engagementLevels, type EngagementLevel } from '@/lib/engagementLevel'
import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
import {
@ -42,9 +43,29 @@ export type MunicipalityListSortKey =
| 'deficit'
| 'frescor'
| 'classe'
| 'nivel'
export type MunicipalityListSortDirection = 'asc' | 'desc'
/**
* E14 filter values: the five levels plus the absence of one. "Sem nível" is
* a real answer here — it is the triage queue — so it is a selectable value
* and not the empty selection.
*
* Same sentinel convention as `NO_PARTY_FILTER_VALUE` in `stateDeputyListUrl`
* (second call site; a third should extract the pair sentinel + `exists: false`
* branch). Unlike `party`, the level is a closed enum, so it also gets the
* "all selected → absent" canonicalization of `parseExhaustiveEnumParam`.
*/
export const NO_LEVEL_FILTER_VALUE = 'sem_nivel'
export type MunicipalityListLevelFilterValue = EngagementLevel | typeof NO_LEVEL_FILTER_VALUE
export const municipalityListLevelFilterValues: readonly MunicipalityListLevelFilterValue[] = [
...engagementLevels,
NO_LEVEL_FILTER_VALUE,
]
/**
* E9 allocation queue: the list opens on the decision it exists to serve —
* where the goal is least covered by auditable commitments (biggest deficit
@ -68,6 +89,7 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
deficit: 'Cobertura da meta',
frescor: 'Frescor do sinal',
classe: 'Classe',
nivel: 'Nível',
}
export type MunicipalityListState = {
@ -94,6 +116,12 @@ export type MunicipalityListState = {
* Never holds the full set (same "todas" canonicalization as `trends`).
*/
classes?: MunicipalityTerritorialClass[]
/**
* Multi-select (OR) E14 engagement levels, including "sem nível". Stored,
* so unlike `classes` this one IS part of `buildMunicipalityListWhere`.
* Never holds the full set (same "todos" canonicalization as `trends`).
*/
levels?: MunicipalityListLevelFilterValue[]
/** Candidate number for the map comparison mode (does not filter the list). */
compare?: number
sort?: MunicipalityListSortKey
@ -112,6 +140,7 @@ const municipalityListParamNames = [
'priority',
'trend',
'class',
'level',
'compare',
'sort',
'dir',
@ -135,6 +164,8 @@ const sortKeysWithDescDefault: MunicipalityListSortKey[] = [
'frescor',
// Ordinal, not alphabetical: descending means reduto first.
'classe',
// Ordinal too: descending opens on N4, where the campaign is most invested.
'nivel',
]
export const defaultMunicipalityListSortDir = (
@ -174,6 +205,9 @@ export const formatMunicipalityListSortSummary = (
? 'Ordenado por classe (reduto primeiro)'
: 'Ordenado por classe (marginal primeiro)'
}
if (sort === 'nivel') {
return dir === 'desc' ? 'Ordenado por nível (N4 primeiro)' : 'Ordenado por nível (N0 primeiro)'
}
const label = municipalityListSortLabels[sort]
return dir === 'desc' ? `Ordenado por ${label} ↓` : `Ordenado por ${label} ↑`
}
@ -219,6 +253,7 @@ const parseAdvisorsParam = (raw: string | string[] | undefined): number[] => {
const politicalTrendStatusSet = new Set<string>(Object.keys(politicalTrendLabels))
const territorialClassSet = new Set<string>(Object.keys(territorialClassLabels))
const engagementLevelFilterSet = new Set<string>(municipalityListLevelFilterValues)
export const municipalityListStateToRawParams = (
state: MunicipalityListState,
@ -234,6 +269,7 @@ export const municipalityListStateToRawParams = (
priority: state.priority,
trend: state.trends,
class: state.classes,
level: state.levels,
compare: state.compare === undefined ? undefined : String(state.compare),
sort: state.sort,
dir: state.dir,
@ -258,6 +294,10 @@ export const parseMunicipalityListParams = (
params.class,
territorialClassSet,
)
const levels = parseExhaustiveEnumParam<MunicipalityListLevelFilterValue>(
params.level,
engagementLevelFilterSet,
)
const rawCompare = strictDecimalInteger(firstValue(params.compare))
const rawSort = firstValue(params.sort) as MunicipalityListSortKey | undefined
const sort = rawSort && municipalityListSortKeySet.has(rawSort) ? rawSort : undefined
@ -277,6 +317,7 @@ export const parseMunicipalityListParams = (
...(rawPriority === 'alta' ? { priority: 'alta' } : {}),
...(trends.length ? { trends } : {}),
...(classes.length ? { classes } : {}),
...(levels.length ? { levels } : {}),
...(rawCompare && rawCompare <= 99999 ? { compare: rawCompare } : {}),
...(sort ? { sort } : {}),
...(dir ? { dir } : {}),
@ -305,6 +346,20 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
}
if (state.priority) filters.push({ priority: { equals: state.priority } })
if (state.trends?.length) filters.push({ 'politicalTrend.status': { in: state.trends } })
if (state.levels?.length) {
// "Sem nível" is absence, which no `in` can express — it rides along as an
// OR branch so "N0 ou sem nível" (the triage view) is one query.
const selectedLevels = state.levels.filter(
(level): level is EngagementLevel => level !== NO_LEVEL_FILTER_VALUE,
)
const levelFilters: Where[] = []
if (selectedLevels.length) levelFilters.push({ engagementLevel: { in: selectedLevels } })
if (state.levels.includes(NO_LEVEL_FILTER_VALUE)) {
levelFilters.push({ engagementLevel: { exists: false } })
}
const [onlyFilter, ...restFilters] = levelFilters
filters.push(onlyFilter && restFilters.length === 0 ? onlyFilter : { or: levelFilters })
}
// `state.classes` is absent on purpose: the class is derived from the TSE
// artifact, not stored, so it can't be a Payload constraint. `municipalityPageData`
// filters it in memory over the unpaginated scope.
@ -339,6 +394,7 @@ export const serializeCanonicalMunicipalityListSearchParams = (
for (const territorialClass of canonicalState.classes ?? []) {
params.append('class', territorialClass)
}
for (const level of canonicalState.levels ?? []) params.append('level', level)
if (canonicalState.compare) params.set('compare', String(canonicalState.compare))
// Omit the default pair (staff: deficit+desc). Keep `sort` whenever the pair
// is non-default so `dir` is never orphaned (e.g. votos+asc → sort=votos&dir=asc).
@ -408,6 +464,9 @@ const formatMunicipalitySortOptionLabel = (
if (key === 'classe') {
return dir === 'asc' ? `${base} (marginal primeiro)` : `${base} (reduto primeiro)`
}
if (key === 'nivel') {
return dir === 'asc' ? `${base} (N0 primeiro)` : `${base} (N4 primeiro)`
}
return dir === 'asc' ? `${base} (A–Z)` : `${base} (Z–A)`
}

View file

@ -3,6 +3,7 @@ import 'server-only'
import type { Payload, Where } from 'payload'
import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
import { engagementLevelRank } from '@/lib/engagementLevel'
import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog'
import {
compareMunicipalityVotesForSort,
@ -196,6 +197,9 @@ const payloadSortFieldByKey: Partial<Record<MunicipalityListSortKey, string>> =
kind: 'kind',
lastUpdateAt: 'lastUpdateAt',
trend: 'politicalTrend.status',
// E9 (deficit, frescor), E10 (classe) and E14 (nivel) are absent on purpose:
// the first three are derived, and `nivel` needs its NULLs at the end, which
// the query cannot express.
}
const municipalityNameCompare = (left: Municipality, right: Municipality): number =>
@ -273,6 +277,14 @@ const applyDerivedMunicipalitySort = (
return now - new Date(lastSignalAt).getTime()
})
}
case 'nivel':
// Stored, so Postgres could order it — but Postgres puts NULLs FIRST on
// DESC, and with no backfill that hands "N4 primeiro" a page of "Sem
// nível". Absence of a decision is not the top of the ladder: it sorts
// like `sem_base` above, at the end in either direction.
return sortByNullableValue(docs, dir, (municipality) =>
municipality.engagementLevel ? engagementLevelRank[municipality.engagementLevel] : null,
)
case 'classe':
// Ordinal weight, not the label: descending means reduto first, and
// `sem_base` (no weight) lands at the end in either direction.

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import type { EngagementLevel } from '@/lib/engagementLevel'
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
import {
toVoteEstimateScenarioViewModel,
@ -42,6 +43,9 @@ export const municipalityListSelect = {
status: true,
note: true,
},
engagementLevel: true,
levelNote: true,
levelChangedAt: true,
} as const
type MunicipalityPoliticalTrendViewModel = {
@ -72,6 +76,10 @@ export type MunicipalityListViewModel = {
expectedVotes: VoteEstimateScenarioViewModel
politicalTrendStatus: PoliticalTrendStatus | null
politicalTrendNote: string | null
/** E14 — staff-only; field access leaves it undefined for anyone else. */
engagementLevel: EngagementLevel | null
levelNote: string | null
levelChangedAt: string | null
pledges: MunicipalityPledgeAggregate
votePosition2022: MunicipalityVoteRankEntry | null
/** E10 — classe operacional derivada do artefato TSE. */
@ -111,6 +119,9 @@ export const toMunicipalityListViewModel = (
expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes),
politicalTrendStatus: municipality.politicalTrend?.status ?? null,
politicalTrendNote: municipality.politicalTrend?.note ?? null,
engagementLevel: municipality.engagementLevel ?? null,
levelNote: municipality.levelNote ?? null,
levelChangedAt: municipality.levelChangedAt ?? null,
pledges: pledges ?? createEmptyMunicipalityPledgeAggregate(),
votePosition2022,
territorialClass: territorialClass.class,
@ -151,6 +162,10 @@ export type MunicipalityStrategyViewModel = {
priority: 'alta' | 'normal'
expectedVotes: VoteEstimateScenarioViewModel
politicalTrend: MunicipalityPoliticalTrendViewModel
/** E14 — the ladder and the motivo behind the current rung. */
engagementLevel: EngagementLevel | null
levelNote: string | null
levelChangedAt: string | null
strengths: string[]
risks: string[]
stateDeputyIDs: number[]
@ -193,6 +208,9 @@ export const toMunicipalityDetailViewModel = (
recordedByName: trendRecordedByName,
recordedAt: municipality.politicalTrend?.recordedAt ?? null,
},
engagementLevel: municipality.engagementLevel ?? null,
levelNote: municipality.levelNote ?? null,
levelChangedAt: municipality.levelChangedAt ?? null,
strengths: (municipality.strengths ?? []).map((item) => item.text),
risks: (municipality.risks ?? []).map((item) => item.text),
stateDeputyIDs: (municipality.stateDeputies ?? [])

View file

@ -27,6 +27,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
// compile before any spec's client makes the real request.
for (const path of [
'/campanha/municipios/advisors',
'/campanha/municipios/engagement-level',
'/campanha/municipios/expected-votes',
'/campanha/municipios/political-trend',
'/campanha/liderancas/support-status',

View file

@ -999,6 +999,8 @@ export class CampaignFixtures {
priority: 'normal',
expectedVotes: { pessimistic: null, central: null, optimistic: null },
politicalTrend: { status: null, note: null, recordedBy: null, recordedAt: null },
engagementLevel: null,
levelNote: null,
strengths: [],
risks: [],
dobradinhaNotes: null,

View file

@ -0,0 +1,262 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { setMunicipalityEngagementLevelRecord } from '@/app/(campaign)/campanha/actions/municipality'
import { ENGAGEMENT_LEVEL_PATTERN_ID, EngagementLevelBlockedError } from '@/lib/engagementLevel'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const findDecisions = (municipalityID: number) =>
payload.find({
collection: 'allocationDecision',
where: { municipality: { equals: municipalityID } },
sort: 'createdAt',
depth: 0,
pagination: false,
})
describe('municipality engagement level (E14)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('records the level and its movement decision in one transaction', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const updated = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n3',
note: 'Prefeitura aliada e duas lideranças novas.',
reversalSignals: 'Rompimento do prefeito ou pledges abaixo de 40% da meta.',
})
fixtures.touchMunicipality(municipality.id)
expect(updated.engagementLevel).toBe('n3')
expect(updated.levelNote).toBe('Prefeitura aliada e duas lideranças novas.')
expect(updated.levelChangedAt).toBeTruthy()
const decisions = await findDecisions(municipality.id)
expect(decisions.docs).toHaveLength(1)
const decision = decisions.docs[0]!
fixtures.own('allocationDecision', decision.id)
expect(decision.patternId).toBe(ENGAGEMENT_LEVEL_PATTERN_ID)
expect(decision.outcome).toBe('movimento')
expect(decision.rationale).toBe('Prefeitura aliada e duas lideranças novas.')
expect(decision.decidedBy).toBe(coordinator.id)
expect(decision.snapshot).toMatchObject({
from: null,
to: 'n3',
reversalSignals: 'Rompimento do prefeito ou pledges abaixo de 40% da meta.',
violations: [],
overridden: false,
})
})
it('holds a movement that breaks the rules and writes nothing', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n1',
note: 'Entrada no mapa como presença de mandato.',
reversalSignals: 'Nenhuma agenda executada no trimestre.',
})
fixtures.touchMunicipality(municipality.id)
// Two levels up, no triangulated shock, and the level was recorded seconds
// ago — every rule at once.
const blocked = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n3',
note: 'Achismo da semana.',
reversalSignals: 'Nada.',
}).catch((error: unknown) => error)
expect(blocked).toBeInstanceOf(EngagementLevelBlockedError)
expect((blocked as EngagementLevelBlockedError).violations.map((v) => v.id)).toContain(
'salto-de-dois-niveis',
)
const unchanged = await payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
})
expect(unchanged.engagementLevel).toBe('n1')
const decisions = await findDecisions(municipality.id)
expect(decisions.docs).toHaveLength(1)
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
})
it('lets the coordinator override knowingly and records what was overridden', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n1',
note: 'Entrada no mapa.',
reversalSignals: 'Agenda vazia por um trimestre.',
})
fixtures.touchMunicipality(municipality.id)
const moved = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n2',
note: 'Adversário abriu comitê e o prefeito pediu presença.',
reversalSignals: 'Comitê adversário fechar.',
override: true,
})
expect(moved.engagementLevel).toBe('n2')
const decisions = await findDecisions(municipality.id)
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
expect(decisions.docs).toHaveLength(2)
const latest = decisions.docs.at(-1)!
expect(latest.snapshot).toMatchObject({
from: 'n1',
to: 'n2',
overridden: true,
// A promotion is not held by the protection window — that rule guards
// against undoing a decision, not against deepening it.
violations: ['dois-movimentos-no-mes'],
})
})
it('accepts a two-level jump when a triangulated shock is declared', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await payload.update({
collection: 'municipality',
id: municipality.id,
data: { engagementLevel: 'n1' },
depth: 0,
})
fixtures.touchMunicipality(municipality.id)
// Push the clock out of both windows so the shock is the only thing at play.
await payload.db.drizzle.execute(
`UPDATE municipality SET level_changed_at = now() - interval '90 days' WHERE id = ${municipality.id}`,
)
const moved = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n3',
note: 'Três sinais independentes de invasão na mesma semana.',
reversalSignals: 'Adversário recuar do território.',
triangulatedShock: true,
})
expect(moved.engagementLevel).toBe('n3')
const decisions = await findDecisions(municipality.id)
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
expect(decisions.docs).toHaveLength(1)
expect(decisions.docs[0]!.snapshot).toMatchObject({
from: 'n1',
to: 'n3',
triangulatedShock: true,
overridden: false,
})
})
it('keeps the ladder with unrestricted staff — the advisor proposes, it does not move', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality.id, [advisor.id])
await expect(
setMunicipalityEngagementLevelRecord(payload, advisor, {
municipality: municipality.id,
level: 'n2',
note: 'Quero subir o meu município.',
reversalSignals: 'Nada.',
}),
).rejects.toThrow('coordenação geral ou o candidato')
const decisions = await findDecisions(municipality.id)
expect(decisions.docs).toHaveLength(0)
})
it('hides the level from the leader and shows it to the advisor read-only', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const leaderAccount = await fixtures.createCampaignUser('leader')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality.id, [advisor.id])
await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
user: leaderAccount.id,
supportStatus: 'engajado',
})
await setMunicipalityEngagementLevelRecord(payload, coordinator, {
municipality: municipality.id,
level: 'n4',
note: 'Prioridade máxima do ciclo.',
reversalSignals: 'Queda de captura por dois meses.',
})
fixtures.touchMunicipality(municipality.id)
const decisions = await findDecisions(municipality.id)
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
const advisorRead = await payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
user: advisor,
overrideAccess: false,
})
expect(advisorRead.engagementLevel).toBe('n4')
// The advisor may edit the município, but not this field: Payload drops a
// field the actor cannot write instead of failing the whole update, so the
// proof is that the level did not move.
const advisorAttempt = await payload.update({
collection: 'municipality',
id: municipality.id,
data: { engagementLevel: 'n0', levelNote: 'Assessor tentou mexer.' },
depth: 0,
user: advisor,
overrideAccess: false,
})
expect(advisorAttempt.engagementLevel).toBe('n4')
expect(advisorAttempt.levelNote).toBe('Prioridade máxima do ciclo.')
// The leader cannot read the município at all (lockdown), which is what
// keeps the ladder off their surface.
await expect(
payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
user: leaderAccount,
overrideAccess: false,
}),
).rejects.toThrow()
})
})

View file

@ -338,6 +338,51 @@ describe('loadMunicipalityListPageBundle', () => {
expect(sorted.municipalities.map((row) => row.slug)).toEqual([reduto.slug, marginal.slug])
})
/**
* E14: `nivel` is a stored column, so ordering is Postgres's. With no
* backfill almost every município is null, and where the nulls land decides
* what page 1 of "N4 primeiro" actually shows — so it is pinned rather than
* inferred from the label.
*/
it('sorts by nível with the un-levelled municípios last in both directions', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const low = await fixtures.getMunicipality(municipalityCatalog[0]!.slug)
const high = await fixtures.getMunicipality(municipalityCatalog[1]!.slug)
const none = await fixtures.getMunicipality(municipalityCatalog[2]!.slug)
const marker = `e14-nivel-${Date.now()}`
for (const [municipality, suffix, engagementLevel] of [
[low, 'n0', 'n0'],
[high, 'n4', 'n4'],
[none, 'sem', null],
] as const) {
await payload.update({
collection: 'municipality',
id: municipality.id,
data: { name: `${marker}-${suffix}`, engagementLevel },
depth: 0,
overrideAccess: true,
})
fixtures.touchMunicipality(municipality.id)
}
const descending = await loadMunicipalityListPageBundle(payload, coordinator, {
q: marker,
sort: 'nivel',
dir: 'desc',
})
expect(descending.municipalities.map((row) => row.engagementLevel)).toEqual(['n4', 'n0', null])
const ascending = await loadMunicipalityListPageBundle(payload, coordinator, {
q: marker,
sort: 'nivel',
dir: 'asc',
})
expect(ascending.municipalities.map((row) => row.engagementLevel)).toEqual(['n0', 'n4', null])
})
it('keeps advisor access and applies URL filters on top', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')

View file

@ -41,6 +41,7 @@ const noopListFormAction = async (
const municipalityListDefaultProps = {
isCoordinator: false,
canMoveEngagementLevel: false,
advisorOptions: [],
columnFilterOptions: { name: [], region: [], advisor: [] },
signalFormAction: noopListFormAction,
@ -174,6 +175,9 @@ describe('campaign visual foundation', () => {
expectedVotes: { pessimistic: null, central: 1500, optimistic: null },
politicalTrendStatus: 'favoravel',
politicalTrendNote: null,
engagementLevel: null,
levelNote: null,
levelChangedAt: null,
pledges: {
declaredTotal: 1200,
effectiveByScenario: { pessimistic: 1200, central: 1200, optimistic: 1200 },
@ -214,6 +218,9 @@ describe('campaign visual foundation', () => {
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
engagementLevel: null,
levelNote: null,
levelChangedAt: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'expansao',
@ -246,12 +253,12 @@ describe('campaign visual foundation', () => {
expect(html).toContain('Cobertura da meta')
expect(html).toContain('80%')
expect(html).toContain('Faltam 300 votos para a meta')
// B22: every one of the 10 staff columns carries a header explanation —
// B22: every one of the 11 staff columns carries a header explanation —
// pinned inside `<thead>` itself, since `cellTooltip` (e.g. "Classe") and
// the mobile card's own class tooltip (B42) add triggers of their own.
const [, tbodyHtml = ''] = html.split('<tbody')
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10)
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
// B41: horizontal scroll + sticky Município (Territory pattern).
expect(html).toMatch(/data-slot="table-container"[^>]*class="[^"]*overflow-x-auto/)
@ -348,6 +355,9 @@ describe('campaign visual foundation', () => {
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
engagementLevel: null,
levelNote: null,
levelChangedAt: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'expansao',
@ -389,6 +399,9 @@ describe('campaign visual foundation', () => {
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
engagementLevel: null,
levelNote: null,
levelChangedAt: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'expansao',
@ -427,6 +440,9 @@ describe('campaign visual foundation', () => {
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
engagementLevel: null,
levelNote: null,
levelChangedAt: null,
pledges: {
declaredTotal: 800,
effectiveByScenario: { pessimistic: 800, central: 800, optimistic: 800 },
@ -471,6 +487,9 @@ describe('campaign visual foundation', () => {
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
engagementLevel: null,
levelNote: null,
levelChangedAt: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'expansao',
@ -520,10 +539,10 @@ describe('campaign visual foundation', () => {
})
it('wraps the advisor cell in a tooltip only when there is a name to show', () => {
// The staff `<thead>` already carries 10 header-explanation tooltips
// The staff `<thead>` already carries 11 header-explanation tooltips
// (B22), regardless of row data. This baseline isolates the advisors
// CELL tooltip, the one this test actually exercises.
const HEADER_TOOLTIP_COUNT = 10
const HEADER_TOOLTIP_COUNT = 11
const baseMunicipality = {
id: 1,
name: 'Seabra',
@ -539,6 +558,9 @@ describe('campaign visual foundation', () => {
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
engagementLevel: null,
levelNote: null,
levelChangedAt: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'sem_base' as const,

View file

@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import {
ENGAGEMENT_LEVEL_RULES,
engagementLevelRank,
engagementLevels,
getEngagementLevelViolations,
isEngagementLevel,
type EngagementLevel,
type EngagementLevelViolationId,
} from '@/lib/engagementLevel'
const NOW = new Date('2026-07-27T12:00:00.000Z')
const daysBefore = (days: number): string =>
new Date(NOW.getTime() - days * 24 * 60 * 60 * 1000).toISOString()
const violationIds = (input: {
from: EngagementLevel | null
to: EngagementLevel
levelChangedAt?: string | null
triangulatedShock?: boolean
}): EngagementLevelViolationId[] =>
getEngagementLevelViolations({
from: input.from,
to: input.to,
levelChangedAt: input.levelChangedAt ?? null,
now: NOW,
triangulatedShock: input.triangulatedShock ?? false,
}).map((violation) => violation.id)
describe('engagement ladder', () => {
it('ranks the levels in ladder order', () => {
expect(engagementLevels.map((level) => engagementLevelRank[level])).toEqual([0, 1, 2, 3, 4])
})
it('recognizes only the five levels', () => {
expect(engagementLevels.every(isEngagementLevel)).toBe(true)
expect(isEngagementLevel('n5')).toBe(false)
expect(isEngagementLevel(null)).toBe(false)
expect(isEngagementLevel(2)).toBe(false)
})
})
describe('engagement level movement rules', () => {
it('treats the first level as a starting point, not a movement', () => {
expect(violationIds({ from: null, to: 'n4' })).toEqual([])
})
it('allows a single-step movement outside the protection window', () => {
expect(violationIds({ from: 'n2', to: 'n3', levelChangedAt: daysBefore(60) })).toEqual([])
})
it('holds a two-level jump unless a triangulated shock is declared', () => {
const levelChangedAt = daysBefore(60)
expect(violationIds({ from: 'n1', to: 'n3', levelChangedAt })).toEqual(['salto-de-dois-niveis'])
expect(violationIds({ from: 'n1', to: 'n3', levelChangedAt, triangulatedShock: true })).toEqual(
[],
)
})
it('protects a fresh level from being undone, but only downwards', () => {
const levelChangedAt = daysBefore(ENGAGEMENT_LEVEL_RULES.protectionWindowDays - 1)
expect(violationIds({ from: 'n3', to: 'n2', levelChangedAt })).toContain('janela-de-protecao')
expect(violationIds({ from: 'n3', to: 'n4', levelChangedAt })).not.toContain(
'janela-de-protecao',
)
})
it('lets a rebaixamento through once the window closed and the month turned', () => {
expect(violationIds({ from: 'n3', to: 'n2', levelChangedAt: daysBefore(40) })).toEqual([])
})
it('counts by calendar month, so a movement early in the month still blocks a second one', () => {
// 26 days is past the 21-day protection window, and the month rule is what
// is left holding it — the two rules are not the same rule.
expect(
violationIds({ from: 'n2', to: 'n1', levelChangedAt: '2026-07-01T09:00:00.000Z' }),
).toEqual(['dois-movimentos-no-mes'])
expect(
violationIds({ from: 'n2', to: 'n1', levelChangedAt: '2026-06-30T09:00:00.000Z' }),
).toEqual([])
})
it('reports every reason at once so the coordinator overrides them knowingly', () => {
expect(violationIds({ from: 'n4', to: 'n2', levelChangedAt: daysBefore(2) })).toEqual([
'salto-de-dois-niveis',
'janela-de-protecao',
'dois-movimentos-no-mes',
])
})
it('ignores a movement to the level already recorded', () => {
expect(violationIds({ from: 'n2', to: 'n2', levelChangedAt: daysBefore(1) })).toEqual([])
})
it('survives an unparseable stored timestamp instead of inventing a violation', () => {
expect(violationIds({ from: 'n2', to: 'n1', levelChangedAt: 'nao-e-uma-data' })).toEqual([])
})
})

View file

@ -183,6 +183,72 @@ describe('E10 classe territorial filter (derived, not a Payload constraint)', ()
})
})
describe('E14 nível de envolvimento (stored: sort and filter both reach the query)', () => {
it('opens the nivel sort on N4 (desc default) and names the direction', () => {
const base = parseMunicipalityListParams({})
expect(buildMunicipalitySortHref(base, 'nivel')).toBe('/campanha/municipios?sort=nivel')
const byLevel = parseMunicipalityListParams({ sort: 'nivel' })
expect(resolveMunicipalityListSort(byLevel)).toEqual({ sort: 'nivel', dir: 'desc' })
expect(formatMunicipalityListSortSummary('nivel', 'desc')).toBe(
'Ordenado por nível (N4 primeiro)',
)
expect(formatMunicipalityListSortSummary('nivel', 'asc')).toBe(
'Ordenado por nível (N0 primeiro)',
)
})
it('constrains the query by the selected levels', () => {
const state = parseMunicipalityListParams({ level: ['n3', 'n4', 'n3'] })
expect(state.levels).toEqual(['n3', 'n4'])
expect(isMunicipalityColumnFilterActive(state, 'level')).toBe(true)
expect(buildMunicipalityListWhere(state)).toEqual({
and: [{ engagementLevel: { in: ['n3', 'n4'] } }],
})
expect(buildMunicipalityFilterHref(state)).toBe('/campanha/municipios?level=n3&level=n4')
})
/**
* "Sem nível" is the triage queue, so it must be selectable — and absence
* can't ride inside an `in`, which is why it becomes its own OR branch.
*/
it('expresses "sem nível" as absence, alone or beside real levels', () => {
expect(buildMunicipalityListWhere(parseMunicipalityListParams({ level: 'sem_nivel' }))).toEqual(
{ and: [{ engagementLevel: { exists: false } }] },
)
expect(
buildMunicipalityListWhere(parseMunicipalityListParams({ level: ['n0', 'sem_nivel'] })),
).toEqual({
and: [{ or: [{ engagementLevel: { in: ['n0'] } }, { engagementLevel: { exists: false } }] }],
})
})
it('discards unknown levels and treats "todos" as absent', () => {
expect(parseMunicipalityListParams({ level: 'n9' }).levels).toBeUndefined()
const all = parseMunicipalityListParams({
level: ['n0', 'n1', 'n2', 'n3', 'n4', 'sem_nivel'],
})
expect(all.levels).toBeUndefined()
expect(buildMunicipalityListWhere(all)).toEqual({})
expect(isMunicipalityColumnFilterActive(all, 'level')).toBe(false)
const allButOne = parseMunicipalityListParams({
level: ['n0', 'n1', 'n2', 'n3', 'n4'],
})
expect(
toggleMunicipalityMultiFilterValue(allButOne, 'level', 'sem_nivel').levels,
).toBeUndefined()
})
it('names the level in the active-filters summary', () => {
expect(
formatMunicipalityActiveFiltersSummary(
parseMunicipalityListParams({ level: ['n4', 'sem_nivel'] }),
),
).toBe('Nível N4, sem nível')
})
})
describe('municipality list header filters (B16+)', () => {
it('toggles multi region OR filters and preserves sort/dir', () => {
const state = parseMunicipalityListParams({
@ -394,6 +460,18 @@ describe('buildMunicipalityFilterOptionHref (B16+ fast path)', () => {
param: 'trend',
value: 'neutra',
},
{
name: 'adds "sem nível" beside a level',
state: parseMunicipalityListParams({ level: ['n0'] }),
param: 'level',
value: 'sem_nivel',
},
{
name: 'collapses the full level set to "todos"',
state: parseMunicipalityListParams({ level: ['n0', 'n1', 'n2', 'n3', 'n4'] }),
param: 'level',
value: 'sem_nivel',
},
]
it.each(cases)('matches the canonical toggle+href path: $name', ({ state, param, value }) => {