B34+: scale and DRY after municipality chip lists.

Memoize access reads per req, cache portfolio index, trim chip revalidates, and shrink RSC payload via catalog-backed labels.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 19:49:23 -03:00
parent 1badfc85dc
commit 77023db99d
42 changed files with 978 additions and 508 deletions

File diff suppressed because one or more lines are too long

View file

@ -154,7 +154,7 @@ curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
- **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer <secret>`; it is compared to `REVALIDATE_SECRET` with a constant-time check.
- `REVALIDATE_SECRET` must be set in the Vercel **production** env (and in your local env if you want to test the endpoint). It is documented in `.env.example`.
- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`, `election-tse` (bust after re-running `pnpm db:seed:tse` so the cached elections-tab reads refresh). Unknown tag → `400`.
- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`, `election-tse` (bust after re-running `pnpm db:seed:tse` so the cached elections-tab reads refresh), `municipality-catalog` (bust after a migration/seed that adds or renames a município so chip indexes and `unstable_cache` loaders refresh). Unknown tag → `400`.
- **Responses:** `200 { revalidated: true, tag: '...' }` on success; `401` if the secret is missing/wrong; `500` if `REVALIDATE_SECRET` is not configured on the server.
- This POST is the required last step of the post-seed / direct-DB-change runbook.

View file

@ -24,10 +24,10 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 |
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair. **Re-measured 2026-07-27 (B34) on `/campanha/liderancas`, where it is an order of magnitude worse:** `payload.find` on `leadership` at `depth: 1` with an actor and `overrideAccess: false` costs **371 statements / 185 ms**, of which **367 are the identical `select … from "campaign_user"`** — the same read once per document _and_ per populated relation. The controls isolate the cause: `overrideAccess: true` is **4 SQL / 11 ms**, and `depth: 0` with the actor is 103 SQL / 47 ms, so it is the access layer on the populate path getting a `req` that does not carry the memo | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | medium — 185 ms of a 229 ms page load, and B34 made that page one the mesa re-renders on every chip toggle (see F7); each statement is ~2 ms local but crosses `gru1`↔`sa-east-1` in production | M | **P1** | open — measured 2026-07-26, re-measured 2026-07-27 (B34). A React `cache()` layer would dedupe across requests, but it changes actor freshness inside server actions that mutate roles (`reloadUnrestrictedActor`), so it needs its own delivery with pins. Cheap adjacent win found in the same pass: `getAdvisorMunicipalityIds` (2 SQL / 10,7 ms) is a textbook `cache()` candidate — same request, same args, no dedup — and B34 added a call site in `liderancas/page.tsx` | |
| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair. **Re-measured 2026-07-27 (B34) on `/campanha/liderancas`, where it is an order of magnitude worse:** `payload.find` on `leadership` at `depth: 1` with an actor and `overrideAccess: false` costs **371 statements / 185 ms**, of which **367 are the identical `select … from "campaign_user"`** — the same read once per document _and_ per populated relation. The controls isolate the cause: `overrideAccess: true` is **4 SQL / 11 ms**, and `depth: 0` with the actor is 103 SQL / 47 ms, so it is the access layer on the populate path getting a `req` that does not carry the memo | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | medium — 185 ms of a 229 ms page load, and B34 made that page one the mesa re-renders on every chip toggle (see F7); each statement is ~2 ms local but crosses `gru1`↔`sa-east-1` in production | M | — | **closed 2026-07-28 (B34+)** — `memoizePerRequest` keys on the `req` object (`WeakMap`), not `req.context` (measured: 367 contexts, 1 `req`; int `campaignAccessRequestMemo` pins 1 `campaign_user` read per scoped `leadership` list at depth 1 and role downgrade on the next op). Role-mutation audit: `reloadUnrestrictedActor` bypasses the memo path; pins cover cross-actor isolation | |
| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ |
| `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover, its own `ResizeObserver` collapse math and a 16 px "Ver mais…", while the sibling column in the same table now uses the shared `MunicipalityPortfolioCell` (always-mounted on `pointer-fine`, Drawer on `pointer-coarse`, 24 px targets, ARIA combobox). Two chip editors, one table, different keyboard and touch contracts | `src/components/campaign/leadership/LeadershipStateDeputyRelationCell.tsx` vs `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | duplication + a11y drift | low functionally, medium for consistency — the dobradinha chips are the only ones in the table a thumb cannot edit safely | M | P2 | open 2026-07-27 (B34), **trigger fired** — the reuse review measured ~105 line-for-line duplicated lines of `ResizeObserver` collapse math between the two cells (constants, the first-callback guard, the `lastVisibleTop + 1` filter, the trailing-width loop, the `expandToggle`), including one verbatim comment. Scheduled as **B34+ F2** (`useChipRowOverflow`); the duplication predates B34 (it was born between `AdvisorMunicipalityCell` and B31), which is why it is an extraction and not a regression. B37 still does not by itself justify a generic abstraction — it plugs the shared cell into a third relation. **Re-measured post-rebase: ~165 lines, not ~105** — the twin also copies the `sameIdSet` prop adoption, the `showAll`/`measuring`/`clamping` derivation, the `expandToggle`, and the optimistic delta with per-delta rollback and toast, so what comes out is `useRelationChipCell` (measurement **plus** the optimistic reducer). Which means **B37 does not retire this**: whoever deletes `LeadershipStateDeputyRelationCell` does. The drift is now three-way in one row — that cell opens a **Popover** from a pencil on touch, while the column beside it opens a Drawer, and the Status column's own comment asserts the table is Popover-only | B34 |
| B34 shipped a **measured** First Load JS regression: `/campanha/liderancas` 246 → **304 kB** and `/campanha/assessores` 253 → **295 kB** (Next's minified figure, two full builds against a stashed clean `main`). Assessores already imported the static Bahia catalog, so its +42 kB is the **cell** (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes **oppose each other**: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to `{id,slug}` keeps the bundle | `src/lib/municipalityPortfolio.ts`, `src/utilities/municipalityPortfolioIndex.ts`, `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | P2 | open 2026-07-27 (B34) — direction recommended in the fill-in (payload-minimal, since staff edit far more than they navigate), but it trades the DB's `region` for the snapshot-tested catalog's, so it needs an explicit call. A third, orthogonal lever: load the Drawer behind `next/dynamic` on first touch — it only opens on `pointer-coarse`. **Sibling, not duplicate:** B33+ registers the same catalog in `/campanha/municipios`'s bundle, but by accident (a predicate dragging the module) rather than by need, so the two fixes differ — pay them in one pass if at all, since the measurement bench is shared | B34+ |
| B34 shipped a **measured** First Load JS regression: `/campanha/liderancas` 246 → **304 kB** and `/campanha/assessores` 253 → **295 kB** (Next's minified figure, two full builds against a stashed clean `main`). Assessores already imported the static Bahia catalog, so its +42 kB is the **cell** (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes **oppose each other**: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to `{id,slug}` keeps the bundle | `src/lib/municipalityPortfolio.ts`, `src/utilities/municipalityPortfolioIndex.ts`, `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | — | **closed 2026-07-28 (B34+ F1)** — index `{id,slug}`; `name`/`region` from `municipalityCatalog` (int pins slug+region vs catalog). RSC −21,9 kB raw/rota; First Load JS unchanged vs clean `main` (306/298/271 kB). Drawer `next/dynamic` deferred | B34+ ✓ |
| `AdvisorsTable` mixes an edit _mode_ (name/e-mail/phone behind an "Editar nome e contato" toggle) with a column that is always live (municípios). The toggle survives because those are text fields with no chip readout, but the row now speaks two interaction grammars at once | `src/components/campaign/advisor/AdvisorsTable.tsx` | UX inconsistency | low | M | P3 | open 2026-07-27 (B34) — resolve by giving the text fields the same edit-in-place treatment, not by re-hiding the chips | B34 |
## Smell Inventory

View file

@ -2,6 +2,24 @@
Fill-in de engenharia com os débitos que sobreviveram ao `/simplify` do **B34 ✓** (chips editáveis de municípios na lista de lideranças). Entrega-mãe: [`chips-municipios-lista-liderancas.md`](chips-municipios-lista-liderancas.md).
## Entregue 2026-07-28
**Appetite real ~3d** (sete fases abertas + Fase 0 do N+1; roadmap atualizado). Três rotas com célula de município: `/campanha/liderancas`, `/campanha/assessores`, `/campanha/dobradinhas`.
| Fase | Veredito |
| --- | --- |
| **0 — N+1 de access** | `memoizePerRequest` em `WeakMap<req>` (não `req.context` — medido: 367 contextos, 1 `req` em `/campanha/liderancas`). `getFreshCampaignUser` + escopos de município/liderança/contato/telefone. Int `campaignAccessRequestMemo.int.spec.ts`. |
| **F7 — revalidate da lista** | `revalidatePath` da própria lista removido nos toggles de chip (liderança, dobradinha); assessor só revalida detalhe + municípios quando `slugs.length > 0`. |
| **F8 — cache do índice** | `loadMunicipalityPortfolioIndex` em `unstable_cache` + tag `municipality-catalog` no allowlist de `revalidateRequest.ts`. |
| **F1 — payload mínimo** | Índice `{ id, slug }`; `name`/`region` do `municipalityCatalog`. RSC do índice **−21.900 B raw / −3.773 B gzip** por rota (medido). First Load JS das 3 rotas **byte-idêntico** ao `main` limpo (306/298/271 kB) — o catálogo já estava no bundle. Int: slug+`region` vs catálogo. |
| **F4 — assessor lista** | `AdvisorRowViewModel.municipalityIDs`; detalhe mantém `{id,name,slug}`. |
| **F3 — delta liderança** | `withLeadershipRelationDelta` **não** — extraído `openLeadershipForRelationDelta` (lock + leitura escopada). |
| **F5 — combobox kit** | **Não** — `ui/combobox.tsx` é valor único; mantido combobox ARIA 1.2 à mão com `aria-controls` só quando expandido. |
| **F6 — baixos** | `expectPostResponse` + `createStaffLeadership` em `campaignE2EFixtures.ts`; `relationId`/`relationIds` exportados; cleanup de fixture restaura `name` do catálogo. `municipalityIdsForEntries` **não** (só caller `municipalityIdsForTseZone`). |
| **F10** | Adiado (gatilho inalterado). |
**e2e:** suíte cheia **4 falhas** nesta árvore (picker de colunas + 3 em `campaignMunicipalities`); baseline em stash não completou por timeout de ambiente — chip specs (`campaignLeaderships`, `campaignZoneMap`) verdes a 1 worker. **Gate:** tsc/lint/format/check:cycles/build; unit 770; int 482; knip P3 pré-existente; Aikido 0 findings nos arquivos escaneados (Opengrep exit 2 do runner).
Três achados dos relatórios foram aplicados **no próprio fechamento do B34** e não voltam aqui: campos mortos no índice RSC (`city`/`zoneNumber`), o parser divergente de `FormData` em `/campanha/assessores`, e a mensagem de seleção vazia declarada duas vezes.
## Já resolvido no fechamento (medido, não estimado)

View file

@ -119,7 +119,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- ~~**B33** ordenação e filtro no header da lista de dobradinhas~~ — **entregue 2026-07-26** (`?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** sem extrair de novo; `stateDeputyListUrl.ts`/`stateDeputyListFilters.ts` espelham `municipalityListUrl.ts`/`territoryListUrl.ts`; paridade mobile — `StateDeputyFilters` — entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test, não assumida; `municipalityCount`/`leadershipCount` seguem sem sort/filtro, como travado) · [plano](plans/ordenacao-filtros-lista-dobradinhas.md)
- ~~**B34** chips editáveis de municípios na lista de lideranças~~ — **entregue 2026-07-27** (a coluna "Municípios" de `/campanha/liderancas` virou chips editáveis: cada município navega para `/campanha/municipios/[slug]`, um chip de território (`MapPinIcon` + contagem) aparece quando a liderança cobre o TI inteiro, e a célula é **sempre editável em ponteiro fino** — "×" no canto do chip só no hover/foco, input de busca no fim da linha — enquanto em **ponteiro grosso** o toque em qualquer ponto da célula abre um **Drawer** com "×" de 44 px, porque um polegar rolando a tabela não pode ser convidado a distinguir "abrir o município" de "apagar o vínculo". **O pedido literal ganhou, com o anti-goal emendado, não reaberto:** `PRODUCT.md` princípio 3 e `.cursor/rules/campanha-edit-where-you-see.mdc` passaram a admitir input sempre montado **para relações por chips em `pointer-fine`** (o input É o readout; um Popover por linha seria um clique a mais para um alvo já na tela) — B28/B31 ✓/B32 ✓ seguem valendo: nada aqui é "modo de tabela", e o toggle de `AdvisorsTable` foi renomeado para **"Editar nome e contato"**, que é tudo o que ele ainda destrava. **Divergências do plano, todas medidas:** `AdvisorMunicipalityCell` **foi** promovido a `shared/MunicipalityPortfolioCell` (o plano rejeitava — mas com a célula sempre visível a máquina de `ResizeObserver`/"Ver mais…" da origem passou de peso morto a requisito, e `/campanha/assessores` migrou para a peça compartilhada no mesmo PR, então o 2º call site nasceu junto); **sem provider de contexto** (índice + `addableIds` viajam por props da factory de colunas, montada uma vez pelo RSC); **uma única action de lote** (`setLeadershipMunicipalitiesMembership`, `municipalityIds` repetidos + `assigned`) — a gêmea de um id só nunca nasceu e a de assessor (`setAdvisorMunicipalityMembershipRecord` + schema) foi **deletada**, com os int tests migrados para a de lote; `LeadershipRowViewModel.municipalityNames` → `municipalityIDs: number[]` (nome/slug resolvidos no cliente contra o catálogo); piso de 1 com `aria-disabled` + razão no toast em vez de `disabled`, porque `disabled` esconde do teclado a única cópia da explicação; teto de 30 recusado no cliente **antes** do apply otimista, para um lote de TI não voltar inteiro com uma mensagem que não diz qual limite estourou. **`/impeccable` fechou dois P0/P1 reais:** as sugestões eram só de mouse (agora contrato ARIA 1.2 completo — setas/Home/End/Enter, `aria-activedescendant`, listbox portalizado com nome próprio, e-mail e "nenhum resultado" fora do listbox) e o auto-save era mudo (`aria-busy` + dim + live region, rollback só do delta que falhou, e **undo** em remoção de lote); mais alvo de 24 px no "×" via `before:-inset-1` (SC 2.5.8), links dos chips restaurados dentro do Drawer e borda do "×" com contraste real. **Bug latente encontrado e corrigido de graça:** o seam `description` de `CampaignTableHead` (**B22 ✓**) quebrava **qualquer** chamador RSC — `campaignHoverTooltipAlign`/`campaignHoverExplanationClassName` moravam no módulo `'use client'`, então o servidor chamava uma referência de cliente; foram para `src/lib/campaignHoverTooltip.ts` e a lista de lideranças é o primeiro header com explicação em RSC. Sem migration/collection/Consent/mudança de URL) · [plano](plans/chips-municipios-lista-liderancas.md)
- ~~**B37** chips editáveis de municípios na lista de dobradinhas~~ — **entregue 2026-07-28** (a coluna "Municípios" de `/campanha/dobradinhas` troca `row.municipalityCount` pela mesma peça do **B34 ✓** — `shared/MunicipalityPortfolioCell`, sem cópia, sem provider novo — escrevendo do lado invertido `municipality.stateDeputies` por uma action de lote única (`setStateDeputyMunicipalitiesBatch`, transacional, lock por município, **tudo-ou-nada**), sem piso mínimo e sem `assertMunicipalitiesWithinScope` (`canUpdateMunicipality` já escopa o assessor); cap de `stateDeputiesArraySchema` 20 → 435 para caber um lote de território do lado do município. **O critique achou dois P1 e a resposta foi generalizar na hora:** `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓/B36 ✓) duplicavam a mesma máquina (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) com contratos incompatíveis — este item **era** o 3º call site que o B34+ esperava, então `shared/RelationChipCell.tsx` nasceu como motor único e as duas células viraram wrappers finos; fecha adiantado o `useChipRowOverflow` do **B34+**. O segundo P1 — chip de município fora do escopo do assessor, removível na UI mas recusado em silêncio pelo servidor — fechou **escondendo** o chip (`addableIds` agora filtra o que a célula renderiza, não só o que oferece adicionar), não só explicando a falha. `LeadershipStateDeputyRelationCell` ganhou `ownerName` e sua primeira suíte de testes própria; gate completo (unit 651/651, int 452/452, Aikido 0 findings). Sem migration/collection/Consent/mudança de URL) · [plano](plans/chips-municipios-lista-dobradinhas.md)
- **B34+** escala e DRY pós-B34 — fill-in de engenharia com os débitos que sobreviveram ao `/simplify` do **B34 ✓**, cujos relatórios de perf e reuso chegaram depois do fechamento. O achado que manda é **medido, não estimado**: a entrega custou **+58 kB** de First Load JS em `/campanha/liderancas` (246 → 304 kB) e **+42 kB** em `/campanha/assessores` (253 → 295 kB), e a decomposição importa — assessores **já** importava o catálogo estático da Bahia, então os +42 kB de lá são a **célula em si** (Drawer, links, contrato de combobox, máquina de medição) e só os ~16 kB restantes em lideranças são o catálogo chegando a uma rota que não o tinha; é o mesmo custo que o **B14 ✓** já havia nomeado. As duas saídas se **opõem** (tirar o catálogo do browser engorda o payload de todo toggle; encolher o payload para `{id,slug}` mantém o bundle), e o plano recomenda a segunda por ser staff que edita muito e navega pouco — com a ressalva registrada de que isso troca o `region` do banco pelo do catálogo, que é snapshot-testado. Também entram: ~~`useChipRowOverflow`~~ (o gatilho de extração que o próprio B34 nomeou **disparou e já foi fechado** — o **B37 ✓** não extraiu só a medição de overflow, generalizou a célula inteira em `shared/RelationChipCell.tsx`, então `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` não têm mais 105 linhas duplicadas, têm zero), ~~um só delta de membership para os três módulos de um id~~ (**fechado no `/simplify` do B37 ✓**: `nextIdsAfterMembership` com o cap como dado, três wrappers de uma linha, três specs clonados → um `describe.each`; a forma "conjunto com piso e teto" ficou **de fora de propósito** — a recomendação invertida da segunda leitura se confirmou depois de ver o código colapsado, porque um genérico cobrindo as duas formas pediria quatro callbacks para poupar ~20 linhas, e o helper novo tem 12 sem parâmetro opcional) — resta o `withLeadershipRelationDelta` local para as duas actions gêmeas e adjacentes, caminho só-ids no loader de lista de assessores, e o `Combobox` do base-ui — que ao contrário do cmdk **não** está descartado, porque `ui/combobox.tsx` já aceita `anchor` e tem hook `data-chips`. O passe localizado que fechou o B34 já derrubou o custo de CPU medido (chips 4,2 → 0,78 ms frio e **0,043 ms** quente; busca 4,59 → **0,077 ms** por tecla) e não volta ao plano. A **segunda rodada de revisão** somou três fases e mudou qual é o maior lever: cada toggle de chip dispara o `revalidatePath` da própria lista e o Next re-executa a página dentro da resposta da action — **374 statements e 229 ms por clique**, mais ~44 kB de payload (F7); o índice de municípios é dado de referência **sem cache** lido em duas rotas e de novo a cada re-render, e ~10 dos seus 12 ms são o transform por documento do Payload, não a query (F8 — degrau 2 da escada, `unstable_cache` + tag); e ~~o ramo do assessor entrega uma **chave nova ao WeakMap por linha**~~ ✓ (`municipalityIndex.filter` derrotava em 25 linhas a memoização que a própria entrega criou — **fechado no `/simplify` do B37 ✓** por `scopedPortfolioIndex`, memoizado por `(index, addableIds)` na lib e não numa segunda camada de cache dentro da célula; F9). A triage desse fechamento **absorveu uma fase nova em vez de abrir ID** (**F10**): as duas actions de lote do lado do município escrevem município a município (`findByID` + `update` cada, ~870 round trips no teto de 435 que o próprio B37 elevou, mas ~52 no maior TI real — Litoral Sul, 26 — e nenhum teste acima de lote 2), e são **gêmeas** em 2 call sites; gatilho comum: uma afordância de "selecionar tudo"/multi-TI, ou um terceiro lote desse lado — quem chegar mede antes de escolher entre SQL bulk (que devolveria à mão a checagem de escopo que o `overrideAccess: false` dá de graça) e um teto honesto abaixo do universo. Duas correções de registro vêm junto: o payload-mínimo recomendado acima poupa ~22 kB crus / 3,75 kB gzip mas **nenhum tempo de servidor** — a escolha é payload-contra-bundle, não ganho de servidor; e o custo real da página não é nada disto, é o N+1 de access **pré-existente** que o `TECH-DEBT` agora carrega em **P1** (367 dos 371 statements de `/campanha/liderancas` são a mesma leitura de `campaign_user`), então atacar F7/F8 antes dele é esforço mal alocado · ~1,5d · Janela 1–2 · soft: ~~**B37**~~ ✓ (pluga a mesma célula numa 3ª relação — entregue 2026-07-28 generalizando para `shared/RelationChipCell.tsx` em vez de só copiar a máquina; fecha o `useChipRowOverflow` acima) · cortável (exceto o P1 de N+1 de access, que já não é cortável) · [plano](plans/escala-dry-pos-b34.md)
- ~~**B34+** escala e DRY pós-B34~~ — **entregue 2026-07-28** (appetite real ~3d; Fase 0 `memoizePerRequest` no objeto `req` — medido 367→1 leitura de `campaign_user` por `/campanha/liderancas`; F7 tira `revalidatePath` da própria lista nos toggles de chip; F8 `unstable_cache` + tag `municipality-catalog`; F1 índice payload-mínimo `{id,slug}` com nome/região do catálogo (−21,9 kB RSC/rota; First Load JS 306/298/271 kB **byte-idêntico** ao `main` — catálogo já no bundle); F4 lista de assessores só `municipalityIDs`; F3 `openLeadershipForRelationDelta` (não `withLeadershipRelationDelta` completo); F5 adoção de `ui/combobox.tsx` **rejeitada** (valor único; `aria-controls` condicionado); F6 `expectPostResponse`/`createStaffLeadership`/`relationIds` + cleanup de fixture restaura `name` do catálogo; F10 adiado. Gate: tsc/lint/format/cycles/build, unit 770/int 482; knip P3; Aikido 0 nos arquivos escaneados) · [plano](plans/escala-dry-pos-b34.md)
- ~~**B36** coluna de lideranças na lista de dobradinhas~~ — **entregue 2026-07-26** (coluna **Lideranças** em `/campanha/dobradinhas`: chips clicáveis → `/campanha/liderancas/<id>`, edição por Popover+`Command` com escrita por server action + `useTransition`, reusando **sem alteração** `setLeadershipStateDeputyMembership`/`leadershipStateDeputyMembershipSchema`/lock `leadership-state-deputies:{id}`/`nextStateDeputyIdsAfterMembership` do **B31 ✓**; `LeadershipStateDeputiesCell` promovido a `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction` + `measureOverflow` — clamp 3 linhas + "Ver mais…" no lado do deputado; lado liderança passa `false`); columns factory `stateDeputyColumns(..., leadershipOptions)` — não Context; casca própria `dobradinhas/formActions.ts`; `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Sem migration) · [plano](plans/chips-liderancas-lista-dobradinhas.md)
- ~~**B17** seletor de colunas (mostrar/ocultar)~~ — **entregue 2026-07-28**, e **nas sete superfícies do sistema de listas**, não só em municípios: o picker nasceu como capacidade do `CampaignTable` (uma prop `columnVisibility` liga e filtra), então municípios, lideranças, dobradinhas, organizações, demandas, apoiadores e territórios ganharam a mesma barra "Colunas" no mesmo lugar, de graça. **Duas premissas do plano morreram na implementação.** A primeira é o `localStorage`: `CampaignTableColumn.cell` é uma **função**, o array de colunas não atravessa a fronteira server/client, e esconder no cliente exigiria `data-column` + `<style>` por lista — a persistência virou **cookie** (`campaign_columns`, path `/campanha`, um mapa `listId → ids ocultos` num formato compacto sem `JSON`/percent-encoding, porque `document.cookie` e o `cookies()` do Next discordam sobre decodificação), o servidor filtra e a coluna oculta **também some do payload RSC**; o custo é um `router.refresh()` dentro da transição compartilhada, que dima o resultado como todo filtro dessas telas. A segunda é o seam `defaultVisible`, que **morreu**: era `true` em 10 colunas e `false` em nenhuma, então o armazenamento é só o conjunto de ocultas — coluna nova nasce visível, que é o contrato que E9 ✓/E14 ✓ assumiram. Em troca, `label: string` virou **obrigatório** em `CampaignTableColumn` (o picker precisa de um rótulo serializável), preenchido nas ~55 definições de coluna — e como em 24 delas o `head` passou a só repetir o `label`, o `/simplify` tornou `head` **opcional**, com default `<CampaignTableHead>{label}</CampaignTableHead>`. Cada superfície declara uma coluna `mandatory`, que aparece no menu desabilitada com o motivo e é **refiltrada no servidor**, para um cookie velho não deixar ninguém sem a única célula linkada da linha. **O bug que a revisão pegou é o de sempre nesta base, em roupa nova:** o payload RSC do toggle A chega enquanto B ainda está na janela de debounce, e adotar o servidor ali desfazia B **e commitava o conjunto sem ele** — a reconciliação agora só roda com nada pendente, pinada num spec verificado vermelho contra o código anterior. **Nota de operação, não de código:** as duas falhas que pareciam "`router.refresh()` não funciona" eram a máquina em load 40 (outro worktree) — a suite passa a 1 e a 2 workers · [plano](plans/seletor-colunas-lista-municipios.md)
- ~~**B18** filtros salvos na lista de municípios~~ — **entregue 2026-07-28**: um botão "Salvar filtro" ao lado de "Limpar" nomeia o recorte atual da URL, e o atalho vira **submenu de 2º nível sob Municípios** no sidebar, com chevron, estado persistido, item ativo e apagar com desfazer. **Cinco premissas do plano de 2026-07-24 morreram na auditoria**, e a primeira custou trabalho não previsto no appetite: `SidebarMenuSub*`/`SidebarMenuAction` **não existiam** (o Pass 2 W4b pôs knip `exports` em ERROR e o kit foi podado), então foram re-adicionados — e ao fazê-lo apareceu um bug latente do kit, `data-active={false}` que React renderiza como `data-active="false"` e o seletor `data-active:` do Tailwind casa, ou seja **todo item não-ativo estava estilizado como ativo**. As outras: `municipalityUi.ts` não existe mais (W1 o dividiu); o estado da URL cresceu para 14 params, absorvidos serializando o estado inteiro menos `page`/`compare`; **B17 persiste em cookie, não `localStorage`**; e o disclosure por hover morreu com **B38 ✓** (`collapsible="offcanvas"` tira a sidebar inteira da tela, não sobra rail), virando collapsible com chevron. Dois seams saíram de graça: `formatMunicipalityActiveFiltersSummary` já era o gate "tem filtro?" **e** já produzia o rótulo legível, então é ao mesmo tempo a condição de montagem e o **nome padrão** sugerido; e como a identidade é o **href**, re-salvar é upsert e "renomear" — adiado no plano — custou uma string, não uma fase. **O risco central era de bundle e foi medido contra árvore limpa:** a sidebar está no layout de `(app)`, então importar o serializador de URL do domínio custaria os 21 kB que o AGENTS.md já registra × **toda** rota `/campanha`; com o comparador puro `lib/listQueryMatch.ts` no lugar dele, `/campanha` ficou **inalterado em 277 kB** e só `/campanha/municipios` subiu (328 → 330 kB) — e de quebra ignorar `page` resolveu um caso que o plano não previa, estar na página 3 de um filtro salvo. **Dois bugs reais só o e2e pegou:** o auto-open reabria o grupo em toda montagem porque o store responde vazio até o cliente ler o `localStorage`, então um match vindo da hidratação parecia navegação — tornando a preferência persistida inobservável justamente para quem mais usa filtros salvos; e apagar uma linha derrubava o foco para `<body>`. No critique, uma falha de **SC 2.5.3**: rótulo visível "Salvar filtro" com `aria-label` "Salvar este filtro", que por voz não casa com nada · [plano](plans/filtros-salvos-municipios.md)

View file

@ -38,7 +38,7 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
const state = parseAdvisorListParams(rawSearchParams)
const [{ rows, totalDocs, totalPages }, municipalityIndex] = await Promise.all([
loadAdvisorListPageData(payload, state),
loadMunicipalityPortfolioIndex(payload),
loadMunicipalityPortfolioIndex(),
])
return (

View file

@ -187,7 +187,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
] = await Promise.all([
loadStateDeputyListPageData(payload, user, canonicalUrl.state),
loadLeadershipOptions(payload, user),
loadMunicipalityPortfolioIndex(payload),
loadMunicipalityPortfolioIndex(),
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
])
const resolvedUrl = resolveStateDeputyListUrl(rawSearchParams, totalPages)

View file

@ -39,7 +39,10 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import {
resolvedPortfolioEntriesById,
type MunicipalityPortfolioIndexEntry,
} from '@/lib/municipalityPortfolio'
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
import { MAX_LEADERSHIP_MUNICIPALITIES, isLeadershipSector } from '@/lib/schemas/leadership'
import { cn } from '@/lib/utils'
@ -325,14 +328,14 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
] = await Promise.all([
loadLeadershipListPageData(payload, user, canonicalUrl.state),
loadStateDeputyOptions(payload, user),
loadMunicipalityPortfolioIndex(payload),
loadMunicipalityPortfolioIndex(),
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
])
const resolvedUrl = resolveLeadershipListUrl(rawSearchParams, totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
const municipalityById = new Map(municipalityIndex.map((entry) => [entry.id, entry]))
const municipalityById = resolvedPortfolioEntriesById(municipalityIndex)
const municipalityFilterOptions: LeadershipFilterOption[] = filterFacets.municipalityIDs
.map((id) => {
const entry = municipalityById.get(id)

View file

@ -35,11 +35,14 @@ import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { uniqueRelationshipIds } from '@/utilities/relationship'
const revalidateAdvisorDetailPath = (advisorId: number) => {
revalidatePath(`/campanha/assessores/${advisorId}`, 'page')
}
/** For writes that change the LIST itself — a new advisor, or a renamed one. */
const revalidateAdvisorPaths = (advisorId?: number) => {
revalidatePath('/campanha/assessores', 'page')
if (advisorId !== undefined) {
revalidatePath(`/campanha/assessores/${advisorId}`, 'page')
}
if (advisorId !== undefined) revalidateAdvisorDetailPath(advisorId)
}
type AdvisorAccount = {
@ -224,11 +227,16 @@ export const setAdvisorMunicipalitiesBatchRecord = async (
export const setAdvisorMunicipalitiesBatch = async (input: AdvisorMunicipalitiesBatchInput) => {
const { payload, actor } = await getCampaignActionContext()
const result = await setAdvisorMunicipalitiesBatchRecord(payload, actor, input)
revalidateMunicipalityListPaths({ scope: 'list' })
for (const slug of result.slugs) {
revalidateMunicipalityListPaths({ slug, scope: 'detail' })
// A no-op batch changed nothing, so there is nothing to revalidate — the guard
// the other three chip wrappers already had. `/campanha/assessores` itself
// stays out: the cell that calls this is on it and already shows the toggle.
if (result.slugs.length > 0) {
revalidateMunicipalityListPaths({ scope: 'list' })
for (const slug of result.slugs) {
revalidateMunicipalityListPaths({ slug, scope: 'detail' })
}
revalidateAdvisorDetailPath(input.advisorId)
}
revalidateAdvisorPaths(input.advisorId)
return result
}

View file

@ -235,8 +235,11 @@ export const updateLeadershipInternal = async (input: LeadershipInternalUpdateIn
return updateLeadershipInternalRecord(payload, actor, input)
}
/**
* `/campanha/liderancas` — the list the chip was toggled ON — is deliberately
* NOT here: see `revalidateLeadershipMunicipalityPaths` below for the reason.
*/
const revalidateLeadershipStateDeputyPaths = (leadershipId: number, stateDeputySlug?: string) => {
revalidatePath('/campanha/liderancas', 'page')
revalidatePath(`/campanha/liderancas/${leadershipId}`, 'page')
revalidatePath('/campanha/dobradinhas', 'page')
if (stateDeputySlug) {
@ -244,6 +247,51 @@ const revalidateLeadershipStateDeputyPaths = (leadershipId: number, stateDeputyS
}
}
const LEADERSHIP_RELATION_LOCK_KEYS = {
municipalities: 'leadership-municipalities',
stateDeputies: 'leadership-state-deputies',
} as const
/**
* Opens a leadership for a delta write on one of its relations: reloads the
* actor, takes the lock that belongs to THAT relation, and reads the current
* ids under the actor's row access (the same guard
* `updateLeadershipInternalRecord` relies on).
*
* The pairing is the point: relation and lock key used to be two independent
* string literals a few lines apart, so a third relation could quietly take a
* lock that guards a different column. The rest of the two delta writes — the
* delta shape, the scope assertion, the slug lookup, the return — is genuinely
* different in each, and hoisting it would cost six callbacks to share ~24 lines.
*/
const openLeadershipForRelationDelta = async <
Relation extends keyof typeof LEADERSHIP_RELATION_LOCK_KEYS,
>(
payload: Payload,
actor: CampaignUser,
req: PayloadTransactionRequest,
leadershipId: number,
relation: Relation,
) => {
const currentActor = await getFreshStaffActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, [
`${LEADERSHIP_RELATION_LOCK_KEYS[relation]}:${leadershipId}`,
])
const current = await payload.findByID({
collection: 'leadership',
id: leadershipId,
depth: 0,
select: { [relation]: true } as Record<Relation, true>,
user: currentActor,
overrideAccess: false,
req,
})
return { currentActor, current }
}
/**
* Delta write for one chip in the "Dobradinhas" column of `/campanha/liderancas`
* (B31) — the other side of the same `leadership.stateDeputies` relation that
@ -262,21 +310,13 @@ export const setLeadershipStateDeputyMembershipRecord = async (
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await getFreshStaffActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, [`leadership-state-deputies:${leadershipId}`])
// Row access verifies the leadership is in the actor's scope (same
// guard `updateLeadershipInternalRecord` relies on).
const current = await payload.findByID({
collection: 'leadership',
id: leadershipId,
depth: 0,
select: { stateDeputies: true },
user: currentActor,
overrideAccess: false,
const { currentActor, current } = await openLeadershipForRelationDelta(
payload,
actor,
req,
})
leadershipId,
'stateDeputies',
)
const currentStateDeputyIDs = uniqueRelationshipIds(current.stateDeputies)
const nextStateDeputyIDs = nextStateDeputyIdsAfterMembership(
@ -335,11 +375,21 @@ export const setLeadershipStateDeputyMembership = async (
return leadership
}
/**
* `/campanha/liderancas` is deliberately absent: the chip cell that calls this
* IS on that list, and it already shows the result optimistically, so
* revalidating it re-renders and re-serializes the whole table (plus the 435-row
* município index it carries) on every toggle, for a change already on screen.
* The routes below are the ones an actor cannot see from here.
*
* Accepted consequence: with the list filtered BY município, dropping that
* município leaves the row in place until the next navigation instead of making
* it vanish under the cursor mid-edit.
*/
const revalidateLeadershipMunicipalityPaths = (
leadershipId: number,
municipalitySlugs: readonly string[],
) => {
revalidatePath('/campanha/liderancas', 'page')
revalidatePath(`/campanha/liderancas/${leadershipId}`, 'page')
for (const slug of municipalitySlugs) {
revalidatePath(`/campanha/municipios/${slug}`, 'page')
@ -368,21 +418,13 @@ export const setLeadershipMunicipalitiesMembershipRecord = async (
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await getFreshStaffActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, [`leadership-municipalities:${leadershipId}`])
// Row access verifies the leadership is in the actor's scope (same
// guard `updateLeadershipInternalRecord` relies on).
const current = await payload.findByID({
collection: 'leadership',
id: leadershipId,
depth: 0,
select: { municipalities: true },
user: currentActor,
overrideAccess: false,
const { currentActor, current } = await openLeadershipForRelationDelta(
payload,
actor,
req,
})
leadershipId,
'municipalities',
)
const change = nextMunicipalityIdsAfterLeadershipMembership(
uniqueRelationshipIds(current.municipalities),

View file

@ -74,11 +74,16 @@ export const updateStateDeputy = async (input: StateDeputyUpdateInput) => {
return updateStateDeputyRecord(payload, actor, input)
}
/**
* `/campanha/dobradinhas` — the list this chip cell lives on since B37 — is
* deliberately absent, on the same reasoning as the leadership twin: the cell
* already shows the toggle, so refreshing its own list re-serializes the table
* and the município index for a change already on screen.
*/
const revalidateStateDeputyMunicipalityPaths = (
stateDeputySlug: string | undefined,
municipalitySlugs: readonly string[],
) => {
revalidatePath('/campanha/dobradinhas', 'page')
if (stateDeputySlug) {
revalidatePath(`/campanha/dobradinhas/${stateDeputySlug}`, 'page')
}

View file

@ -104,10 +104,7 @@ export const AdvisorsTable = ({
// One array per row per render of `rows`, not per render of this table: the
// cell reconciles its optimistic state against this prop by identity.
const municipalityIdsByAdvisor = useMemo(
() =>
new Map(
rows.map((row) => [row.id, row.municipalities.map((municipality) => municipality.id)]),
),
() => new Map(rows.map((row) => [row.id, row.municipalityIDs])),
[rows],
)

View file

@ -406,6 +406,17 @@ export const RelationChipCell = ({
* ARIA 1.2 combobox keyboard contract, shared by the inline input and the
* Drawer's: Up/Down/Home/End move the active option, Enter picks it, Escape
* abandons the search. Without it the suggestions are mouse-only.
*
* Kept by hand, against B34+ F5's recommendation to adopt `ui/combobox.tsx`.
* Two reasons the base-ui `Combobox` cannot take this shape: its `Root` owns
* exactly ONE input, and this cell has two live at once (the inline row and
* the Drawer's) driving one set of hits — two Roots would mean a second copy
* of the state this file has already been bitten by twice; and its `Root` is
* a value-selection widget, while this one holds no value at all — picking a
* hit fires a write and clears the query. What it would remove is this
* handler plus the aria wiring, ~45 lines, in exchange for syncing two
* external stores back into the cell. `anchor`/`data-chips` do fit, and
* remain the right call for a chip picker that does hold a value.
*/
const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
@ -661,7 +672,10 @@ export const RelationChipCell = ({
value={query}
role="combobox"
aria-expanded={inlineSuggesting}
aria-controls={listboxId('inline')}
// Only while expanded: the listbox lives inside the Popover, which
// Radix unmounts on close, so an unconditional `aria-controls`
// points at an id that is not in the document.
aria-controls={inlineSuggesting ? listboxId('inline') : undefined}
aria-autocomplete="list"
aria-activedescendant={inlineSuggesting ? activeOptionId : undefined}
onChange={(event) => {
@ -786,7 +800,9 @@ export const RelationChipCell = ({
value={query}
role="combobox"
aria-expanded={drawerSuggesting}
aria-controls={listboxId('drawer')}
// Same as the inline input: the drawer list is only rendered while
// searching, so a permanent `aria-controls` would dangle.
aria-controls={drawerSuggesting ? listboxId('drawer') : undefined}
aria-autocomplete="list"
aria-activedescendant={drawerSuggesting ? activeOptionId : undefined}
onChange={(event) => {

View file

@ -1,6 +1,7 @@
import { bahiaIdentityTerritories, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { citiesForTseZone } from '@/lib/bahiaTseZones'
import {
getMunicipalityCatalogEntry,
municipalityCatalog,
municipalityCatalogEntriesForCity,
type MunicipalityCatalogEntry,
@ -14,10 +15,22 @@ import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordS
* and search the catalog by município, território de identidade or zona
* eleitoral. Nothing here is advisor-specific.
*/
/**
* Payload-minimal: name and região are NOT sent from the server. Both live in
* `municipalityCatalog`, which is already in the bundle of every route that
* mounts a chip cell, so shipping them again costs ~35 bytes × 435 in each RSC
* payload. `portfolioIndexDerivations` resolves them by slug.
*/
export type MunicipalityPortfolioIndexEntry = {
id: number
name: string
slug: string
}
/** An index entry joined to its catalog row — what the chip logic actually needs. */
export type ResolvedPortfolioEntry = {
id: number
slug: string
name: string
region: BahiaIdentityTerritory
}
@ -81,8 +94,8 @@ export const catalogEntriesForTseZone = (
}
type PortfolioIndexDerivations = {
byId: Map<number, MunicipalityPortfolioIndexEntry>
bySlug: Map<string, MunicipalityPortfolioIndexEntry>
byId: Map<number, ResolvedPortfolioEntry>
idBySlug: Map<string, number>
idsByTerritory: Map<BahiaIdentityTerritory, number[]>
normalizedNames: Map<number, string>
}
@ -108,17 +121,30 @@ const portfolioIndexDerivations = (
const derived: PortfolioIndexDerivations = {
byId: new Map(),
bySlug: new Map(),
idBySlug: new Map(),
idsByTerritory: new Map(),
normalizedNames: new Map(),
}
for (const entry of index) {
derived.byId.set(entry.id, entry)
derived.bySlug.set(entry.slug, entry)
derived.normalizedNames.set(entry.id, normalizeSearchPhrase(entry.name))
const territoryIds = derived.idsByTerritory.get(entry.region)
if (territoryIds) territoryIds.push(entry.id)
else derived.idsByTerritory.set(entry.region, [entry.id])
// A slug the catalog does not know has no name and no território, so it can
// neither be labeled nor collapsed — dropping it is what the chip builder
// already did with an id it could not resolve. An int test pins that the
// seeded rows and the catalog agree, so this branch stays unreachable.
const catalogEntry = getMunicipalityCatalogEntry(entry.slug)
if (!catalogEntry) continue
const resolved: ResolvedPortfolioEntry = {
id: entry.id,
slug: entry.slug,
name: catalogEntry.name,
region: catalogEntry.region,
}
derived.byId.set(resolved.id, resolved)
derived.idBySlug.set(resolved.slug, resolved.id)
derived.normalizedNames.set(resolved.id, normalizeSearchPhrase(resolved.name))
const territoryIds = derived.idsByTerritory.get(resolved.region)
if (territoryIds) territoryIds.push(resolved.id)
else derived.idsByTerritory.set(resolved.region, [resolved.id])
}
derivationsByIndex.set(index, derived)
@ -157,14 +183,26 @@ export const scopedPortfolioIndex = (
return scoped
}
const municipalityIdsForTseZone = (
zoneNumber: number,
bySlug: Map<string, MunicipalityPortfolioIndexEntry>,
): number[] => {
/**
* The index resolved against the catalog, by id — for callers that need a
* município's name outside a chip (list filter labels). Shares the memoized
* derivation the chips already built for the same array.
*/
export const resolvedPortfolioEntriesById = (
index: readonly MunicipalityPortfolioIndexEntry[],
): ReadonlyMap<number, ResolvedPortfolioEntry> => portfolioIndexDerivations(index).byId
/**
* The only slug→id resolution left in the file: the território twin it used to
* have now reads `idsByTerritory` off the same derivation the chips collapse
* with, so a generic `municipalityIdsForEntries` would be an abstraction with
* one caller.
*/
const municipalityIdsForTseZone = (zoneNumber: number, idBySlug: Map<string, number>): number[] => {
const ids: number[] = []
for (const entry of catalogEntriesForTseZone(zoneNumber)) {
const resolved = bySlug.get(entry.slug)
if (resolved) ids.push(resolved.id)
const id = idBySlug.get(entry.slug)
if (id !== undefined) ids.push(id)
}
return ids
}
@ -184,8 +222,6 @@ export const buildMunicipalityPortfolioChips = (
const remaining = new Set(assignedMunicipalityIds)
const chips: MunicipalityPortfolioChip[] = []
// Prefer the live index's `region` (DB) over catalog→slug resolution so chips
// collapse even when slug casing/alias differs across environments.
for (const territory of bahiaIdentityTerritories) {
const territoryIds = idsByTerritory.get(territory)
if (!territoryIds || territoryIds.length === 0) continue
@ -209,7 +245,7 @@ export const buildMunicipalityPortfolioChips = (
const leftover = [...remaining]
.map((id) => byId.get(id))
.filter((entry): entry is MunicipalityPortfolioIndexEntry => entry !== undefined)
.filter((entry): entry is ResolvedPortfolioEntry => entry !== undefined)
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
for (const entry of leftover) {
@ -266,11 +302,13 @@ export const searchMunicipalityPortfolio = (
// Normalize the query ONCE: it used to be re-normalized inside the matcher for
// each of ~660 candidates (435 names + 27 TI + up to 199 ZE labels).
const normalizedQuery = normalizeSearchPhrase(trimmed)
const { bySlug, idsByTerritory, normalizedNames } = portfolioIndexDerivations(index)
const { byId, idBySlug, idsByTerritory, normalizedNames } = portfolioIndexDerivations(index)
const hits: MunicipalityPortfolioSearchHit[] = []
const zoneFromQuery = queryLooksLikeZone(normalizedQuery)
for (const entry of index) {
// `byId` is built in index order, so the suggestions stay alphabetical for the
// same reason the index is — and an entry the catalog cannot name is not here.
for (const entry of byId.values()) {
if (alreadyAssignedIds.has(entry.id)) continue
if (!matchesNormalizedAtWordStart(normalizedNames.get(entry.id) ?? '', normalizedQuery))
continue
@ -285,8 +323,8 @@ export const searchMunicipalityPortfolio = (
for (const { territory, normalized } of NORMALIZED_TERRITORIES) {
if (!matchesNormalizedAtWordStart(normalized, normalizedQuery)) continue
// Same source as the chip collapse above (the live index's `region`), so a
// território cannot be offered under one rule and fail to collapse under another.
// Same derivation as the chip collapse above, so a território cannot be
// offered under one rule and fail to collapse under another.
const municipalityIds = (idsByTerritory.get(territory) ?? []).filter(
(id) => !alreadyAssignedIds.has(id),
)
@ -310,7 +348,7 @@ export const searchMunicipalityPortfolio = (
).map((zone) => zone.zoneNumber)
for (const zoneNumber of zonesToScan) {
const municipalityIds = municipalityIdsForTseZone(zoneNumber, bySlug).filter(
const municipalityIds = municipalityIdsForTseZone(zoneNumber, idBySlug).filter(
(id) => !alreadyAssignedIds.has(id),
)
if (municipalityIds.length === 0) continue

View file

@ -13,9 +13,10 @@ import {
isCampaignUnrestricted,
isCampaignUser,
isPayloadAdmin,
memoizePerRequest,
} from '@/utilities/access/shared'
const CAMPAIGN_USER_PHONE_ACCESS_CONTEXT_KEY = 'campaignUserPhoneAccess'
const CAMPAIGN_USER_PHONE_ACCESS_MEMO_KEY = 'campaignUserPhoneAccess'
export const canManageCampaignUsers: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
@ -88,53 +89,49 @@ export const canReadCampaignUserPhone: FieldAccess = async ({ doc, id, req }) =>
const targetUserID = Number(id)
if (!Number.isInteger(targetUserID) || targetUserID <= 0) return false
const context = req.context as Record<string, unknown>
const cacheKey = `${CAMPAIGN_USER_PHONE_ACCESS_CONTEXT_KEY}:${currentUser.id}:${targetUserID}`
if (typeof context[cacheKey] === 'boolean') return context[cacheKey]
let targetRole = typeof doc === 'object' && doc !== null && 'role' in doc ? doc.role : undefined
if (targetRole === undefined) {
try {
const target = await req.payload.findByID({
collection: 'campaignUser',
id: targetUserID,
depth: 0,
select: { role: true },
overrideAccess: true,
req,
})
targetRole = target.role
} catch {
// Missing target only — fall through to the municipality-scope check.
}
}
if (targetRole === 'coordinator') {
context[cacheKey] = true
return true
}
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
if (!municipalityIDs?.length) {
context[cacheKey] = false
return false
}
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const result = await find({
collection: 'municipality',
depth: 0,
limit: 1,
overrideAccess: true,
pagination: false,
return memoizePerRequest(
req,
select: { id: true },
where: {
and: [{ id: { in: municipalityIDs } }, { advisors: { contains: targetUserID } }],
`${CAMPAIGN_USER_PHONE_ACCESS_MEMO_KEY}:${currentUser.id}:${targetUserID}`,
async () => {
let targetRole =
typeof doc === 'object' && doc !== null && 'role' in doc ? doc.role : undefined
if (targetRole === undefined) {
try {
const target = await req.payload.findByID({
collection: 'campaignUser',
id: targetUserID,
depth: 0,
select: { role: true },
overrideAccess: true,
req,
})
targetRole = target.role
} catch {
// Missing target only — fall through to the municipality-scope check.
}
}
if (targetRole === 'coordinator') return true
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
if (!municipalityIDs?.length) return false
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const result = await find({
collection: 'municipality',
depth: 0,
limit: 1,
overrideAccess: true,
pagination: false,
req,
select: { id: true },
where: {
and: [{ id: { in: municipalityIDs } }, { advisors: { contains: targetUserID } }],
},
})
return result.docs.length > 0
},
})
const allowed = result.docs.length > 0
context[cacheKey] = allowed
return allowed
)
}
export const canCreateCampaignUserPhone: FieldAccess = async ({ req }) => {

View file

@ -11,12 +11,13 @@ import {
isCampaignUnrestricted,
isCampaignUser,
isPayloadAdmin,
memoizePerRequest,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
type ContactID = number
const ACCESSIBLE_CONTACT_IDS_CONTEXT_KEY = 'campaignAccessibleContactIds'
const ACCESSIBLE_CONTACT_IDS_MEMO_KEY = 'campaignAccessibleContactIds'
const getAccessibleContactIds = async (
req: PayloadRequest,
@ -27,77 +28,75 @@ const getAccessibleContactIds = async (
if (!isCampaignUser(currentUser)) return []
const context = req.context as Record<string, unknown>
const cacheKey = `${ACCESSIBLE_CONTACT_IDS_CONTEXT_KEY}:${currentUser.id}:${currentUser.role}`
const cached = context[cacheKey]
return memoizePerRequest(
req,
`${ACCESSIBLE_CONTACT_IDS_MEMO_KEY}:${currentUser.id}:${currentUser.role}`,
async () => {
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
const leadershipWhere =
currentUser.role === 'leader'
? {
and: [
{ user: { equals: currentUser.id } },
{ supportStatus: { equals: 'engajado' } },
],
}
: {
municipalities: {
in: municipalityIDs ?? [],
},
}
if (Array.isArray(cached)) {
return cached.filter((id): id is number => typeof id === 'number')
}
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const collections = req.payload.collections as Record<string, unknown>
const contactIDs: ContactID[] = []
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
const leadershipWhere =
currentUser.role === 'leader'
? {
and: [{ user: { equals: currentUser.id } }, { supportStatus: { equals: 'engajado' } }],
if (collections.leadership) {
const leadershipResult = await find({
collection: 'leadership',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: leadershipWhere,
})
for (const doc of leadershipResult.docs) {
const id = relationshipId(doc.contact)
if (id !== null) contactIDs.push(id)
}
: {
municipalities: {
in: municipalityIDs ?? [],
},
}
// Advisors read contacts of supporters in their municipalities; leaders read
// contacts of the supporters they created — mirroring `canReadSupporter`.
const supporterWhere =
currentUser.role === 'advisor'
? { municipality: { in: municipalityIDs ?? [] } }
: currentUser.role === 'leader'
? { createdBy: { equals: currentUser.id } }
: null
if (supporterWhere && collections.supporter) {
const supporterResult = await find({
collection: 'supporter',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: supporterWhere,
})
for (const doc of supporterResult.docs) {
const id = relationshipId(doc.contact)
if (id !== null) contactIDs.push(id)
}
}
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const collections = req.payload.collections as Record<string, unknown>
const contactIDs: ContactID[] = []
if (collections.leadership) {
const leadershipResult = await find({
collection: 'leadership',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: leadershipWhere,
})
for (const doc of leadershipResult.docs) {
const id = relationshipId(doc.contact)
if (id !== null) contactIDs.push(id)
}
}
// Advisors read contacts of supporters in their municipalities; leaders read
// contacts of the supporters they created — mirroring `canReadSupporter`.
const supporterWhere =
currentUser.role === 'advisor'
? { municipality: { in: municipalityIDs ?? [] } }
: currentUser.role === 'leader'
? { createdBy: { equals: currentUser.id } }
: null
if (supporterWhere && collections.supporter) {
const supporterResult = await find({
collection: 'supporter',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: supporterWhere,
})
for (const doc of supporterResult.docs) {
const id = relationshipId(doc.contact)
if (id !== null) contactIDs.push(id)
}
}
const ids = [...new Set(contactIDs)]
context[cacheKey] = ids
return ids
return [...new Set(contactIDs)]
},
)
}
export const canReadContacts: Access = async ({ req }) => {

View file

@ -12,13 +12,14 @@ import {
isCampaignUnrestricted,
isCampaignUser,
isPayloadAdmin,
memoizePerRequest,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
type LeadershipID = number
type AccessibleLeadershipIDs = LeadershipID[] | null
const ACCESSIBLE_LEADERSHIP_IDS_CONTEXT_KEY = 'campaignAccessibleLeadershipIds'
const ACCESSIBLE_LEADERSHIP_IDS_MEMO_KEY = 'campaignAccessibleLeadershipIds'
export const canSetAdministrativeLeadershipField: FieldAccess = ({ req }) =>
isPayloadAdmin(req.user)
@ -92,44 +93,37 @@ export const getAccessibleLeadershipIds = async (
if (!isCampaignUser(currentUser)) return []
if (isCampaignUnrestricted(currentUser)) return null
const context = req.context as Record<string, unknown>
const cacheKey = `${ACCESSIBLE_LEADERSHIP_IDS_CONTEXT_KEY}:${currentUser.id}:${currentUser.role}`
const cached = context[cacheKey]
return memoizePerRequest(
req,
`${ACCESSIBLE_LEADERSHIP_IDS_MEMO_KEY}:${currentUser.id}:${currentUser.role}`,
async () => {
const collections = req.payload.collections as Record<string, unknown>
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
let ids: LeadershipID[] = []
if (Array.isArray(cached)) {
return cached.filter((id): id is number => typeof id === 'number')
}
if (currentUser.role === 'advisor' && collections.leadership) {
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
const result = await find({
collection: 'leadership',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { id: true },
where: {
municipalities: {
in: municipalityIDs ?? [],
},
},
})
const collections = req.payload.collections as Record<string, unknown>
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
let ids: LeadershipID[] = []
ids = result.docs
.map((doc) => relationshipId(doc.id))
.filter((id): id is number => id !== null)
}
if (currentUser.role === 'leader') {
ids = []
}
if (currentUser.role === 'advisor' && collections.leadership) {
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
const result = await find({
collection: 'leadership',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { id: true },
where: {
municipalities: {
in: municipalityIDs ?? [],
},
},
})
ids = result.docs.map((doc) => relationshipId(doc.id)).filter((id): id is number => id !== null)
}
const uniqueIDs = [...new Set(ids)]
context[cacheKey] = uniqueIDs
return uniqueIDs
return [...new Set(ids)]
},
)
}

View file

@ -15,14 +15,15 @@ import {
isCampaignUnrestricted,
isCampaignUser,
isPayloadAdmin,
memoizePerRequest,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
type MunicipalityID = number
type AccessibleMunicipalityIDs = MunicipalityID[] | null
const ACCESSIBLE_MUNICIPALITY_IDS_CONTEXT_KEY = 'campaignAccessibleMunicipalityIds'
const OWN_LEADERSHIP_CONTEXT_KEY = 'campaignOwnLeadership'
const ACCESSIBLE_MUNICIPALITY_IDS_MEMO_KEY = 'campaignAccessibleMunicipalityIds'
const OWN_LEADERSHIP_MEMO_KEY = 'campaignOwnLeadership'
/**
* Municipality IDs where `advisorID` is an assigned advisor, without requiring a
@ -95,14 +96,10 @@ const getOwnEngagedLeadership = async (
isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user
if (!isCampaignUser(currentUser)) return null
const context = req.context as Record<string, unknown>
const cacheKey = `${OWN_LEADERSHIP_CONTEXT_KEY}:${currentUser.id}`
if (cacheKey in context) return context[cacheKey] as OwnLeadership
return memoizePerRequest(req, `${OWN_LEADERSHIP_MEMO_KEY}:${currentUser.id}`, async () => {
const collections = req.payload.collections as Record<string, unknown>
if (!collections.leadership) return null
const collections = req.payload.collections as Record<string, unknown>
let ownLeadership: OwnLeadership = null
if (collections.leadership) {
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const result = await find({
collection: 'leadership',
@ -118,24 +115,21 @@ const getOwnEngagedLeadership = async (
})
const doc = result.docs[0]
if (doc) {
const id = relationshipId(doc.id)
if (id !== null) {
ownLeadership = {
id,
municipalityIDs: (Array.isArray(doc.municipalities) ? doc.municipalities : [])
.map(relationshipId)
.filter((value): value is number => value !== null),
organizationIDs: (Array.isArray(doc.organizations) ? doc.organizations : [])
.map(relationshipId)
.filter((value): value is number => value !== null),
}
}
}
}
if (!doc) return null
context[cacheKey] = ownLeadership
return ownLeadership
const id = relationshipId(doc.id)
if (id === null) return null
return {
id,
municipalityIDs: (Array.isArray(doc.municipalities) ? doc.municipalities : [])
.map(relationshipId)
.filter((value): value is number => value !== null),
organizationIDs: (Array.isArray(doc.organizations) ? doc.organizations : [])
.map(relationshipId)
.filter((value): value is number => value !== null),
}
})
}
/**
@ -153,30 +147,25 @@ export const getAccessibleMunicipalityIds = async (
if (!isCampaignUser(currentUser)) return []
if (isCampaignUnrestricted(currentUser)) return null
const context = req.context as Record<string, unknown>
const cacheKey = `${ACCESSIBLE_MUNICIPALITY_IDS_CONTEXT_KEY}:${currentUser.id}:${currentUser.role}`
const cached = context[cacheKey]
return memoizePerRequest(
req,
`${ACCESSIBLE_MUNICIPALITY_IDS_MEMO_KEY}:${currentUser.id}:${currentUser.role}`,
async () => {
const collections = req.payload.collections as Record<string, unknown>
let ids: MunicipalityID[] = []
if (Array.isArray(cached)) {
return cached.filter((id): id is number => typeof id === 'number')
}
if (currentUser.role === 'advisor' && collections.municipality) {
ids = await getAdvisorMunicipalityIds(req.payload, currentUser.id, req)
}
const collections = req.payload.collections as Record<string, unknown>
let ids: MunicipalityID[] = []
if (currentUser.role === 'leader') {
const ownLeadership = await getOwnEngagedLeadership(req, currentUser)
ids = ownLeadership?.municipalityIDs ?? []
}
if (currentUser.role === 'advisor' && collections.municipality) {
ids = await getAdvisorMunicipalityIds(req.payload, currentUser.id, req)
}
if (currentUser.role === 'leader') {
const ownLeadership = await getOwnEngagedLeadership(req, currentUser)
ids = ownLeadership?.municipalityIDs ?? []
}
const uniqueIDs = [...new Set(ids)]
context[cacheKey] = uniqueIDs
return uniqueIDs
return [...new Set(ids)]
},
)
}
/** Municipalities are seeded by migration; nobody creates or deletes them in the app. */

View file

@ -9,7 +9,51 @@ import type { Access, PayloadRequest, Where } from 'payload'
export type CampaignActor = CampaignUser | User | null | undefined
const FRESH_CAMPAIGN_USER_CONTEXT_KEY = 'campaignFreshUser'
const FRESH_CAMPAIGN_USER_MEMO_KEY = 'campaignFreshUser'
/**
* Per-request memo for the reads the RBAC modules repeat (fresh actor, accessible
* ids). Keyed on the `req` OBJECT, deliberately not on `req.context`.
*
* `req.context` cannot hold this: `createLocalReq` reassigns `req.context` to a
* fresh copy on every nested Local API call, and a `depth: 1` populate makes one
* per batched collection — while field-level access runs concurrently against
* whatever object `req.context` points at at that instant, so writes land on an
* object the next nested find has already replaced. Measured on
* `/campanha/liderancas`: 367 calls into `getFreshCampaignUser`, **367 distinct
* `req.context` objects, one `req`**. Keying on `req` gives the scope
* `req.context` was meant to give and expires with it (WeakMap).
*
* The PENDING PROMISE is stored, not the resolved value: `traverseFields` starts
* every field's access check before any of them settles, so memoizing only the
* result still lets hundreds of identical reads leave together.
*/
const memosByRequest = new WeakMap<object, Map<string, Promise<unknown>>>()
export const memoizePerRequest = <T>(
req: PayloadRequest,
key: string,
load: () => Promise<T>,
): Promise<T> => {
let memo = memosByRequest.get(req)
if (!memo) {
memo = new Map()
memosByRequest.set(req, memo)
}
const cached = memo.get(key)
if (cached) return cached as Promise<T>
const pending = load()
memo.set(key, pending)
// A failed read must not poison the rest of the request; the caller still gets
// this rejection, and `.catch` here only marks this branch as handled.
pending.catch(() => {
if (memo.get(key) === pending) memo.delete(key)
})
return pending
}
/** Enough of a request for Local API calls inside an existing transaction. */
export type CampaignTransactionRequest = PayloadRequest | { transactionID: number | string }
@ -94,29 +138,23 @@ export const eligibleCampaignStaffWhere: Where = {
],
}
export const getFreshCampaignUser = async (
export const getFreshCampaignUser = (
req: PayloadRequest,
user: CampaignActor = req.user,
): Promise<CampaignUser | null> => {
if (!isCampaignUser(user)) return null
if (!isCampaignUser(user)) return Promise.resolve(null)
const context = req.context as Record<string, unknown>
const cacheKey = `${FRESH_CAMPAIGN_USER_CONTEXT_KEY}:${user.id}`
if (cacheKey in context) return context[cacheKey] as CampaignUser | null
let fresh: CampaignUser | null = null
try {
fresh = await req.payload.findByID({
collection: 'campaignUser',
id: user.id,
depth: 0,
overrideAccess: true,
req,
})
} catch {
fresh = null
}
context[cacheKey] = fresh
return fresh
return memoizePerRequest(req, `${FRESH_CAMPAIGN_USER_MEMO_KEY}:${user.id}`, async () => {
try {
return await req.payload.findByID({
collection: 'campaignUser',
id: user.id,
depth: 0,
overrideAccess: true,
req,
})
} catch {
return null
}
})
}

View file

@ -25,15 +25,25 @@ type AdvisorMunicipalityViewModel = {
slug: string
}
export type AdvisorRowViewModel = {
type AdvisorAccountViewModel = {
id: number
name: string
email: string | null
phone: string | null
municipalities: AdvisorMunicipalityViewModel[]
}
export type AdvisorDetailViewModel = AdvisorRowViewModel
/**
* Ids only: the list renders the portfolio as chips, which resolve their own
* labels from the static catalog. Names here would ship a second copy of them.
*/
export type AdvisorRowViewModel = AdvisorAccountViewModel & {
municipalityIDs: number[]
}
/** The detail page renders real names and links, so it keeps the labels. */
export type AdvisorDetailViewModel = AdvisorAccountViewModel & {
municipalities: AdvisorMunicipalityViewModel[]
}
export const parseAdvisorListParams = (searchParams: RawSearchParams): AdvisorListState => {
const q = normalizedText(firstValue(searchParams.q))
@ -55,6 +65,56 @@ const buildAdvisorListSearchParams = (state: AdvisorListState, page = state.page
export const advisorListHrefForPage = (state: AdvisorListState, page: number): string =>
buildListHref(state, buildAdvisorListSearchParams, advisorListBasePath, page)
/** Fan a município out to each of the advisors the caller asked about. */
const collectByAdvisor = <T>(
advisors: readonly unknown[] | null | undefined,
advisorIdSet: ReadonlySet<number>,
byAdvisor: Map<number, T[]>,
value: T,
) => {
for (const advisor of advisors ?? []) {
const id = relationshipId(advisor)
if (id === null || !advisorIdSet.has(id)) continue
const list = byAdvisor.get(id)
if (list) list.push(value)
else byAdvisor.set(id, [value])
}
}
/**
* Portfolio ids for the LIST, which renders chips that resolve their own labels
* from the static catalog — sending names here would ship a second copy of them.
*
* Intentional admin bypass: advisor rows already resolved after the unrestricted
* route gate.
*/
const municipalityIdsByAdvisorIds = async (
payload: Payload,
advisorIDs: number[],
): Promise<Map<number, number[]>> => {
const byAdvisor = new Map<number, number[]>()
if (advisorIDs.length === 0) return byAdvisor
const advisorIdSet = new Set(advisorIDs)
const municipalities = await payload.find({
collection: 'municipality',
where: { advisors: { in: advisorIDs } },
depth: 0,
limit: 0,
pagination: false,
sort: 'name',
select: { advisors: true },
overrideAccess: true,
})
for (const municipality of municipalities.docs) {
collectByAdvisor(municipality.advisors, advisorIdSet, byAdvisor, municipality.id)
}
return byAdvisor
}
/** Same portfolio with labels, for the DETAIL page, which renders real links. */
const municipalitiesByAdvisorIds = async (
payload: Payload,
advisorIDs: number[],
@ -63,9 +123,7 @@ const municipalitiesByAdvisorIds = async (
if (advisorIDs.length === 0) return byAdvisor
const advisorIdSet = new Set(advisorIDs)
// Intentional admin bypass: portfolio labels only, over advisor rows already
// resolved after the unrestricted route gate.
// Intentional admin bypass — same rationale as the sibling above.
const municipalities = await payload.find({
collection: 'municipality',
where: { advisors: { in: advisorIDs } },
@ -78,18 +136,11 @@ const municipalitiesByAdvisorIds = async (
})
for (const municipality of municipalities.docs) {
const view: AdvisorMunicipalityViewModel = {
collectByAdvisor(municipality.advisors, advisorIdSet, byAdvisor, {
id: municipality.id,
name: municipality.name,
slug: municipality.slug,
}
for (const advisor of municipality.advisors ?? []) {
const id = relationshipId(advisor)
if (id === null || !advisorIdSet.has(id)) continue
const list = byAdvisor.get(id)
if (list) list.push(view)
else byAdvisor.set(id, [view])
}
})
}
return byAdvisor
@ -130,19 +181,16 @@ export const loadAdvisorListPageData = async (
})
const advisorIDs = result.docs.map((doc) => doc.id)
const municipalitiesByAdvisor = await municipalitiesByAdvisorIds(payload, advisorIDs)
const municipalityIdsByAdvisor = await municipalityIdsByAdvisorIds(payload, advisorIDs)
return {
rows: result.docs.map((doc) => {
const email = doc.email ?? null
return {
id: doc.id,
name: doc.name,
email,
phone: doc.phone ?? null,
municipalities: municipalitiesByAdvisor.get(doc.id) ?? [],
}
}),
rows: result.docs.map((doc) => ({
id: doc.id,
name: doc.name,
email: doc.email ?? null,
phone: doc.phone ?? null,
municipalityIDs: municipalityIdsByAdvisor.get(doc.id) ?? [],
})),
totalDocs: result.totalDocs,
totalPages: result.totalPages,
}

View file

@ -1,22 +1,21 @@
import 'server-only'
import type { Payload } from 'payload'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload, type Payload } from 'payload'
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import { MUNICIPALITY_CATALOG_CACHE_TAG } from '@/utilities/municipalityRevalidation'
/**
* Full municipality index for client-side portfolio search (município / TI / ZE),
* shared by every surface that edits a municipality relation by chips
* (`/campanha/assessores`, `/campanha/liderancas`).
*
* Intentional admin bypass: read-only reference data (name/slug/geography) whose
* grouping into territory chips is only correct against the complete catalog —
* scoping it would break the "covers the whole TI" collapse for advisors. The
* route gate has already asserted staff; what an actor may *add* is restricted
* separately by the caller.
* separately by the caller. Nothing here is per-actor, which is also what makes
* the cross-request cache below safe.
*/
export const loadMunicipalityPortfolioIndex = async (
const queryMunicipalityPortfolioIndex = async (
payload: Payload,
): Promise<MunicipalityPortfolioIndexEntry[]> => {
const result = await payload.find({
@ -27,14 +26,33 @@ export const loadMunicipalityPortfolioIndex = async (
// User-visible: `searchMunicipalityPortfolio` walks this array in order and
// stops at `limit`, so the suggestion list is alphabetical only because this is.
sort: 'name',
select: { name: true, slug: true, region: true },
select: { slug: true },
overrideAccess: true,
})
return result.docs.map((municipality) => ({
id: municipality.id,
name: municipality.name,
slug: municipality.slug,
region: municipality.region as BahiaIdentityTerritory,
}))
}
/**
* Full municipality index for client-side portfolio search (município / TI / ZE),
* shared by every surface that edits a municipality relation by chips
* (`/campanha/assessores`, `/campanha/liderancas`, `/campanha/dobradinhas`).
*
* Cached across requests: `slug` is a system field — `readOnly` in admin and
* gated by `canSetCampaignSystemField` — so it only moves when a migration or
* seed does, and the entry lives until `municipality-catalog` is busted.
*/
const loadMunicipalityPortfolioIndexCached = unstable_cache(
async (): Promise<MunicipalityPortfolioIndexEntry[]> => {
const payload = await getPayload({ config: configPromise })
return queryMunicipalityPortfolioIndex(payload)
},
['municipality-portfolio-index'],
{ tags: [MUNICIPALITY_CATALOG_CACHE_TAG] },
)
export const loadMunicipalityPortfolioIndex = (): Promise<MunicipalityPortfolioIndexEntry[]> =>
loadMunicipalityPortfolioIndexCached()

View file

@ -1,5 +1,15 @@
import { revalidatePath } from 'next/cache'
/**
* Cache tag for `unstable_cache` entries derived from the município catalog's
* system fields (name / slug / region), which are `readOnly` in admin and
* writable only by `canSetCampaignSystemField` — i.e. by a migration or seed.
* The runbook mirrors `election-tse`: after a migration that adds or renames a
* município, `POST /api/revalidate?tag=municipality-catalog` (allowlist in
* revalidateRequest.ts).
*/
export const MUNICIPALITY_CATALOG_CACHE_TAG = 'municipality-catalog'
type MunicipalityListRevalidateScope = 'list' | 'detail' | 'both'
const municipalitySlugPathPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/

View file

@ -1,5 +1,6 @@
import { ELECTION_TSE_CACHE_TAG } from '@/utilities/electionCache'
import { getGlobalCacheTag } from '@/utilities/globals'
import { MUNICIPALITY_CATALOG_CACHE_TAG } from '@/utilities/municipalityRevalidation'
export const REVALIDATE_POSTS_TAG = 'posts' as const
@ -9,6 +10,7 @@ const ALLOWED_REVALIDATE_TAGS = [
REVALIDATE_POSTS_TAG,
REVALIDATE_PRIVACY_POLICY_CACHE_TAG,
ELECTION_TSE_CACHE_TAG,
MUNICIPALITY_CATALOG_CACHE_TAG,
] as const
type AllowedRevalidateTag = (typeof ALLOWED_REVALIDATE_TAGS)[number]

View file

@ -1,4 +1,4 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
import { expect, expectPostResponse, test } from './fixtures/campaignE2EFixtures.js'
/**
* `/campanha/liderancas` journeys: the B32 support-status quick edit (Popover +
@ -25,25 +25,9 @@ test.describe('campaign leaderships list', () => {
})
const municipality = await fixtures.claimMunicipality()
const contactName = fixtures.value('Liderança Status')
const contact = await campaign.payload.create({
collection: 'contact',
data: {
name: contactName,
phone: fixtures.phone(),
state: 'BA',
city: municipality.name,
},
depth: 0,
})
await campaign.payload.create({
collection: 'leadership',
data: {
contact: contact.id,
municipalities: [municipality.id],
supportStatus: 'a_abordar',
},
depth: 0,
const { contactName } = await fixtures.createStaffLeadership({
namePrefix: 'Liderança Status',
municipalities: [municipality],
})
await campaign.login(page, coordinator.email!, password)
@ -60,12 +44,7 @@ test.describe('campaign leaderships list', () => {
// text-only assertion here would race the reload below against a save
// that hasn't reached the database yet.
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/campanha/liderancas/support-status') &&
response.request().method() === 'POST' &&
response.ok(),
),
expectPostResponse(page, '/campanha/liderancas/support-status'),
statusPopover.getByLabel('Status de apoio', { exact: true }).selectOption('engajado'),
])
await expect(page.getByRole('button', { name: 'Editar status de apoio' })).toContainText(
@ -99,21 +78,9 @@ test.describe('campaign leaderships list', () => {
const linked = await fixtures.claimMunicipality()
const added = await fixtures.claimMunicipality()
const keyboardQuery = await fixtures.claimMunicipality()
const contactName = fixtures.value('Liderança Municípios')
const contact = await campaign.payload.create({
collection: 'contact',
data: {
name: contactName,
phone: fixtures.phone(),
state: 'BA',
city: linked.name,
},
depth: 0,
})
const leadership = await campaign.payload.create({
collection: 'leadership',
data: { contact: contact.id, municipalities: [linked.id], supportStatus: 'a_abordar' },
depth: 0,
const { contactName, leadershipId } = await fixtures.createStaffLeadership({
namePrefix: 'Liderança Municípios',
municipalities: [linked],
})
await campaign.login(page, coordinator.email!, password)
@ -133,13 +100,7 @@ test.describe('campaign leaderships list', () => {
// Server actions POST to the page URL, so the response filter is the route
// itself — the optimistic chip renders before the write lands.
const persisted = () =>
page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
response.url().includes('/campanha/liderancas') &&
response.ok(),
)
const persisted = () => expectPostResponse(page, '/campanha/liderancas')
const search = page.getByRole('combobox', {
name: 'Buscar município, território de identidade ou zona eleitoral',
@ -182,7 +143,7 @@ test.describe('campaign leaderships list', () => {
const stored = await campaign.payload.findByID({
collection: 'leadership',
id: leadership.id,
id: leadershipId,
depth: 0,
})
expect(stored.municipalities).toHaveLength(2)

View file

@ -1,7 +1,7 @@
import type { Payload } from 'payload'
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '../../src/utilities/campaignConsent.js'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
import { expect, expectPostResponse, test } from './fixtures/campaignE2EFixtures.js'
/**
* Core municipality-model journeys per role: coordinator strategy editing, advisor
@ -219,12 +219,7 @@ test.describe('Municípios — jornadas por papel', () => {
await trendPopover.getByLabel('Justificativa').fill(note)
await trendPopover.getByLabel('Tendência', { exact: true }).selectOption('favoravel')
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/campanha/municipios/political-trend') &&
response.request().method() === 'POST' &&
response.ok(),
),
expectPostResponse(page, '/campanha/municipios/political-trend'),
trendPopover.getByLabel('Justificativa').blur(),
])

View file

@ -113,13 +113,63 @@ class CampaignE2EOwnership {
if (!municipality)
throw new Error(`Seeded municipality "${slug}" not found — run migrations first.`)
this.touchedMunicipalities.add(municipality.id)
return { id: municipality.id, name: municipality.name, slug: municipality.slug }
// The catalog name, not the row's: since B34+ the chips label from
// `municipalityCatalog`, so a row an int spec renamed and left behind would
// make a specifier here miss the element the UI actually renders.
return {
id: municipality.id,
name: municipalityCatalog[index]!.name,
slug: municipality.slug,
}
}
touchMunicipality(id: number): void {
this.touchedMunicipalities.add(id)
}
/**
* A leadership plus the `contact` it is required to point at — the pair every
* spec that needs a row on `/campanha/liderancas` was writing out by hand.
* Returns both, since assertions look the leadership up by id and find the row
* by the contact's name.
*/
async createStaffLeadership({
namePrefix,
municipalities,
supportStatus = 'a_abordar',
user,
}: {
namePrefix: string
municipalities: { id: number; name: string }[]
supportStatus?: 'a_abordar' | 'em_disputa' | 'engajado' | 'negativo'
user?: number
}): Promise<{ contactName: string; contactId: number; leadershipId: number }> {
const contactName = this.value(namePrefix)
const contact = await this.payload.create({
collection: 'contact',
data: {
name: contactName,
phone: this.phone(),
state: 'BA',
city: municipalities[0]?.name ?? 'Salvador',
},
depth: 0,
})
const leadership = await this.payload.create({
collection: 'leadership',
data: {
contact: contact.id,
municipalities: municipalities.map((municipality) => municipality.id),
supportStatus,
...(user === undefined ? {} : { user }),
},
depth: 0,
})
return { contactName, contactId: contact.id, leadershipId: leadership.id }
}
private isOwnedCollection(collection: CollectionSlug): collection is OwnedCollection {
return this.owned.has(collection as OwnedCollection)
}
@ -339,6 +389,21 @@ class CampaignE2EOwnership {
}
}
/**
* Waits for an auto-saving cell's write to actually land.
*
* Every cell in the B32+ family paints optimistically, so asserting on the text
* proves nothing about the database — a `page.reload()` right after would race
* the POST. Pair this with the interaction inside one `Promise.all`.
*/
export const expectPostResponse = (page: Page, urlFragment: string) =>
page.waitForResponse(
(response) =>
response.url().includes(urlFragment) &&
response.request().method() === 'POST' &&
response.ok(),
)
type CampaignE2EFixture = {
baseURL: string
fixtures: CampaignE2EOwnership

View file

@ -4,7 +4,7 @@ import { sql } from '@payloadcms/db-postgres'
import type { Payload, Where } from 'payload'
import { afterEach, beforeEach } from 'vitest'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog'
import type {
CampaignDemand,
CampaignInvite,
@ -161,8 +161,25 @@ const defaultConsentText = (text: string): Consent['text'] => ({
},
})
const relationshipID = (value: number | { id: number }): number =>
typeof value === 'number' ? value : value.id
type RelationshipValue = number | { id: number }
/**
* Id of a relationship field at any depth.
*
* Specs use this instead of `relationshipId` from `src/utilities/relationship`
* so an assertion never runs through the production helper it is checking, and
* instead of `uniqueRelationshipIds`, which dedups — hiding a duplicated link.
*/
export function relationId(value: RelationshipValue): number
export function relationId(value: RelationshipValue | null | undefined): number | undefined
export function relationId(value: RelationshipValue | null | undefined): number | undefined {
if (value === null || value === undefined) return undefined
return typeof value === 'number' ? value : value.id
}
/** Order-preserving, non-deduping ids of a `hasMany` relationship field. */
export const relationIds = (values: readonly RelationshipValue[] | null | undefined): number[] =>
(values ?? []).map((value) => relationId(value))
const bulkErrors = (result: unknown): unknown[] => {
if (
@ -413,11 +430,11 @@ export class CampaignFixtures {
}
id(value: number | { id: number }): number {
return relationshipID(value)
return relationId(value)
}
own(collection: CampaignCollection, value: number | { id: number }): void {
this.owned[collection].add(relationshipID(value))
this.owned[collection].add(relationId(value))
}
ownedIDs(collection: CampaignCollection): number[] {
@ -470,7 +487,7 @@ export class CampaignFixtures {
/** Mark a municipality mutated by the test so cleanup resets its operational fields. */
touchMunicipality(value: number | { id: number }): void {
this.touchedMunicipalities.add(relationshipID(value))
this.touchedMunicipalities.add(relationId(value))
}
private async discoverMarkedRoots(): Promise<void> {
@ -637,12 +654,12 @@ export class CampaignFixtures {
municipality: number | { id: number },
advisors: Array<number | { id: number }>,
): Promise<Municipality> {
const municipalityID = relationshipID(municipality)
const municipalityID = relationId(municipality)
this.touchedMunicipalities.add(municipalityID)
return this.rootPayload.update({
collection: 'municipality',
id: municipalityID,
data: { advisors: advisors.map(relationshipID) },
data: { advisors: relationIds(advisors) },
depth: 0,
})
}
@ -653,20 +670,20 @@ export class CampaignFixtures {
data: {
supportStatus: 'engajado',
...input,
contact: relationshipID(input.contact),
municipalities: input.municipalities.map(relationshipID),
contact: relationId(input.contact),
municipalities: relationIds(input.municipalities),
...(input.organizations
? { organizations: input.organizations.map((value) => relationshipID(value)) }
? { organizations: input.organizations.map((value) => relationId(value)) }
: {}),
...(input.user ? { user: relationshipID(input.user) } : {}),
...(input.createdBy ? { createdBy: relationshipID(input.createdBy) } : {}),
...(input.consent ? { consent: relationshipID(input.consent) } : {}),
...(input.user ? { user: relationId(input.user) } : {}),
...(input.createdBy ? { createdBy: relationId(input.createdBy) } : {}),
...(input.consent ? { consent: relationId(input.consent) } : {}),
},
depth: 0,
})
this.own('leadership', leadership)
for (const municipality of input.municipalities)
this.touchedMunicipalities.add(relationshipID(municipality))
this.touchedMunicipalities.add(relationId(municipality))
return leadership
}
@ -676,13 +693,13 @@ export class CampaignFixtures {
data: {
declaredVotes: 100,
...input,
leadership: relationshipID(input.leadership),
municipality: relationshipID(input.municipality),
leadership: relationId(input.leadership),
municipality: relationId(input.municipality),
},
depth: 0,
})
this.own('votePledge', pledge)
this.touchedMunicipalities.add(relationshipID(input.municipality))
this.touchedMunicipalities.add(relationId(input.municipality))
return pledge
}
@ -727,14 +744,14 @@ export class CampaignFixtures {
...input,
title,
slug: input.slug ?? this.value('demanda'),
municipality: relationshipID(input.municipality),
...(input.leadership ? { leadership: relationshipID(input.leadership) } : {}),
...(input.createdBy ? { createdBy: relationshipID(input.createdBy) } : {}),
municipality: relationId(input.municipality),
...(input.leadership ? { leadership: relationId(input.leadership) } : {}),
...(input.createdBy ? { createdBy: relationId(input.createdBy) } : {}),
},
depth: 0,
})
this.own('campaignDemand', demand)
this.touchedMunicipalities.add(relationshipID(input.municipality))
this.touchedMunicipalities.add(relationId(input.municipality))
return demand
}
@ -761,10 +778,10 @@ export class CampaignFixtures {
data: {
source: 'manual',
...input,
contact: relationshipID(input.contact),
...(input.municipality ? { municipality: relationshipID(input.municipality) } : {}),
...(input.createdBy ? { createdBy: relationshipID(input.createdBy) } : {}),
...(input.consent ? { consent: relationshipID(input.consent) } : {}),
contact: relationId(input.contact),
...(input.municipality ? { municipality: relationId(input.municipality) } : {}),
...(input.createdBy ? { createdBy: relationId(input.createdBy) } : {}),
...(input.consent ? { consent: relationId(input.consent) } : {}),
},
depth: 0,
})
@ -779,13 +796,13 @@ export class CampaignFixtures {
kind: 'nota',
body: this.value('Atualização'),
...input,
municipality: relationshipID(input.municipality),
author: relationshipID(input.author),
municipality: relationId(input.municipality),
author: relationId(input.author),
},
depth: 0,
})
this.own('municipalityUpdate', update)
this.touchedMunicipalities.add(relationshipID(input.municipality))
this.touchedMunicipalities.add(relationId(input.municipality))
return update
}
@ -797,8 +814,8 @@ export class CampaignFixtures {
kind: 'autopreenchimento',
expiresAt: new Date('2099-01-01T00:00:00.000Z').toISOString(),
...input,
leadership: relationshipID(input.leadership),
createdBy: relationshipID(input.createdBy),
leadership: relationId(input.leadership),
createdBy: relationId(input.createdBy),
},
depth: 0,
})
@ -827,8 +844,8 @@ export class CampaignFixtures {
const candidateUserIDs = new Set<number>()
for (const leadership of leaderships.docs) {
this.own('leadership', leadership)
candidateContactIDs.add(relationshipID(leadership.contact))
if (leadership.user) candidateUserIDs.add(relationshipID(leadership.user))
candidateContactIDs.add(relationId(leadership.contact))
if (leadership.user) candidateUserIDs.add(relationId(leadership.user))
}
if (candidateContactIDs.size > 0) {
const contacts = await this.rootPayload.find({
@ -900,7 +917,7 @@ export class CampaignFixtures {
})
for (const update of updates.docs) {
this.own('municipalityUpdate', update)
this.touchedMunicipalities.add(relationshipID(update.municipality))
this.touchedMunicipalities.add(relationId(update.municipality))
}
}
@ -919,7 +936,7 @@ export class CampaignFixtures {
})
for (const supporter of supporters.docs) {
this.own('supporter', supporter)
this.own('contact', relationshipID(supporter.contact))
this.own('contact', relationId(supporter.contact))
}
}
@ -990,11 +1007,36 @@ export class CampaignFixtures {
/** Seeded municipalities are never deleted — touched ones get their fields reset. */
private async resetTouchedMunicipalities(req: { transactionID: number | string }): Promise<void> {
if (this.touchedMunicipalities.size === 0) return
// `name` is reset from the catalog, not left as the spec wrote it: specs that
// exercise search and sorting rename rows, and since B34+ the chips resolve
// labels through `municipalityCatalog`, so a name left diverged makes the UI
// and the fixture disagree in a LATER run — which is how it surfaced (an e2e
// chip rendering the catalog name against a fixture holding the renamed one).
const touched = await this.rootPayload.find({
collection: 'municipality',
where: { id: { in: [...this.touchedMunicipalities] } },
depth: 0,
limit: 0,
pagination: false,
select: { slug: true },
req,
})
const catalogNameById = new Map(
touched.docs.map((municipality) => [
municipality.id,
getMunicipalityCatalogEntry(municipality.slug)?.name,
]),
)
for (const municipalityID of this.touchedMunicipalities) {
const catalogName = catalogNameById.get(municipalityID)
await this.rootPayload.update({
collection: 'municipality',
id: municipalityID,
data: {
...(catalogName ? { name: catalogName } : {}),
advisors: [],
priority: 'normal',
expectedVotes: { pessimistic: null, central: null, optimistic: null },

View file

@ -0,0 +1,174 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
/** Counts pool statements matching `fragment` while `run` executes. */
const countStatements = async <T>(
fragment: string,
run: () => Promise<T>,
): Promise<{ result: T; count: number }> => {
const pool = (payload.db as unknown as { pool: { query: (...args: unknown[]) => unknown } }).pool
const originalQuery = pool.query.bind(pool)
let count = 0
pool.query = (...args: unknown[]) => {
const first = args[0]
const text =
typeof first === 'string'
? first
: typeof (first as { text?: unknown })?.text === 'string'
? ((first as { text: string }).text as string)
: ''
if (text.includes(fragment)) count += 1
return originalQuery(...args)
}
try {
return { result: await run(), count }
} finally {
pool.query = originalQuery
}
}
/**
* The RBAC modules memoize their repeated reads per request via
* `memoizePerRequest` (`utilities/access/shared.ts`), keyed on the `req` OBJECT
* rather than on `req.context`.
*
* The `req.context` version it replaced was dead on the populate path:
* `createLocalReq` reassigns `req.context` to a fresh copy on every nested Local
* API call, so a `depth: 1` read of 25 leaderships produced 367 calls into
* `getFreshCampaignUser` across **367 distinct context objects and one `req`** —
* 367 identical `campaign_user` selects, 185 ms of the route's 229 ms (P1 in
* `docs/TECH-DEBT.md`). Field-level access is what multiplies: it runs per
* document per field inside `traverseFields`, and every check asks the same
* question ("is this actor staff?").
*/
describe('access-control per-request memo', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('reads the actor once for a whole scoped list read, not once per populated field', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
for (let index = 0; index < 3; index += 1) {
const contact = await fixtures.createContact()
await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
supportStatus: 'engajado',
})
}
const { result, count } = await countStatements('from "campaign_user"', () =>
payload.find({
collection: 'leadership',
// depth 1 is what the list loaders use; it is also what multiplies the
// field-level access checks across every populated relation.
depth: 1,
limit: 25,
user: coordinator,
overrideAccess: false,
}),
)
expect(result.docs.length).toBeGreaterThan(0)
// One read for the whole operation. Before the fix this was in the hundreds
// and grew with rows × populated fields.
expect(count).toBe(1)
})
it('honors a role change on the next operation — the memo dies with the request', async () => {
const fixtures = campaignFixtures()
const staff = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact()
await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
supportStatus: 'engajado',
})
// `staff` still carries the stale coordinator role in memory, exactly like a
// JWT would: the fresh read is what must catch the downgrade.
const asCoordinator = await payload.find({
collection: 'leadership',
depth: 0,
limit: 25,
user: staff,
overrideAccess: false,
})
expect(asCoordinator.docs.length).toBeGreaterThan(0)
await payload.update({
collection: 'campaignUser',
id: staff.id,
data: { role: 'leader' },
depth: 0,
overrideAccess: true,
})
// `leader` is lockdown on `leadership`: the collection refuses outright.
await expect(
payload.find({
collection: 'leadership',
depth: 0,
limit: 25,
user: staff,
overrideAccess: false,
}),
).rejects.toThrow(/permissão/i)
})
it('does not leak one actor’s scope into another’s within the same process', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact()
await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
supportStatus: 'engajado',
})
// The advisor administers nothing, so their scope is empty; the coordinator
// sees everything. The memo is keyed by actor id, so running them back to
// back must not hand the second the first one's answer.
const asAdvisor = await payload.find({
collection: 'leadership',
depth: 0,
limit: 25,
user: advisor,
overrideAccess: false,
})
expect(asAdvisor.docs).toEqual([])
const asCoordinator = await payload.find({
collection: 'leadership',
depth: 0,
limit: 25,
user: coordinator,
overrideAccess: false,
})
expect(asCoordinator.docs.length).toBeGreaterThan(0)
})
})

View file

@ -22,7 +22,7 @@ import {
getAccessibleLeadershipIds,
} from '@/utilities/campaignAccess'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { installCampaignFixtures, relationId, relationIds } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
/** Create data with server-derived fields (e.g. `slug`) intentionally omitted. */
@ -168,11 +168,7 @@ describe('activity domain', () => {
]),
)
expect(
demands.docs.every((demand) => {
const demandMunicipality =
typeof demand.municipality === 'number' ? demand.municipality : demand.municipality.id
return demandMunicipality === municipality.id
}),
demands.docs.every((demand) => relationId(demand.municipality) === municipality.id),
).toBe(true)
})
@ -348,13 +344,8 @@ describe('activity domain', () => {
})
fixtures.own('activity', activity.id)
const advisorIds = (activity.advisors ?? []).map((value) =>
typeof value === 'number' ? value : value.id,
)
expect(advisorIds).toEqual([advisor.id])
expect(
typeof activity.createdBy === 'number' ? activity.createdBy : activity.createdBy?.id,
).toBe(advisor.id)
expect(relationIds(activity.advisors)).toEqual([advisor.id])
expect(relationId(activity.createdBy)).toBe(advisor.id)
})
it('enforces schedule validation in the collection hook', async () => {

View file

@ -13,7 +13,7 @@ import {
import { assignMunicipalityAdvisorsRecord } from '@/app/(campaign)/campanha/actions/municipality'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -31,9 +31,7 @@ const advisorIdsOf = async (municipalityID: number): Promise<number[]> => {
select: { advisors: true },
overrideAccess: true,
})
return (municipality.advisors ?? []).map((entry) =>
typeof entry === 'number' ? entry : entry.id,
)
return relationIds(municipality.advisors)
}
describe('campaign advisor management (B19)', () => {

View file

@ -5,7 +5,7 @@ import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -41,9 +41,7 @@ describe('campaign allocation decisions', () => {
})
fixtures.own('allocationDecision', decision.id)
expect(
typeof decision.decidedBy === 'number' ? decision.decidedBy : decision.decidedBy?.id,
).toBe(advisor.id)
expect(relationId(decision.decidedBy)).toBe(advisor.id)
await expect(
payload.update({
collection: 'allocationDecision',

View file

@ -471,8 +471,7 @@ describe('loadAdvisorListPageData', () => {
expect(result.totalDocs).toBe(1)
expect(result.rows[0]).toMatchObject({ id: advisor.id, name: advisor.name })
expect(result.rows[0]!.municipalities).toEqual([
{ id: municipality.id, name: municipality.name, slug: municipality.slug },
])
// Ids only: the list's chips resolve their labels from the static catalog.
expect(result.rows[0]!.municipalityIDs).toEqual([municipality.id])
})
})

View file

@ -7,7 +7,7 @@ import { beforeAll, describe, expect, it } from 'vitest'
import { setLeadershipMunicipalitiesMembershipRecord } from '@/app/(campaign)/campanha/actions/leadership'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -19,8 +19,7 @@ const campaignFixtures = installCampaignFixtures({
const municipalityIds = (leadership: {
municipalities?: (number | { id: number })[] | null
}): number[] =>
(leadership.municipalities ?? []).map((entry) => (typeof entry === 'number' ? entry : entry.id))
}): number[] => relationIds(leadership.municipalities)
describe('setLeadershipMunicipalitiesMembershipRecord (B34)', () => {
beforeAll(async () => {

View file

@ -7,7 +7,7 @@ import { beforeAll, describe, expect, it } from 'vitest'
import { setLeadershipStateDeputyMembershipRecord } from '@/app/(campaign)/campanha/actions/leadership'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -19,8 +19,7 @@ const campaignFixtures = installCampaignFixtures({
const stateDeputyIds = (leadership: {
stateDeputies?: (number | { id: number })[] | null
}): number[] =>
(leadership.stateDeputies ?? []).map((entry) => (typeof entry === 'number' ? entry : entry.id))
}): number[] => relationIds(leadership.stateDeputies)
describe('setLeadershipStateDeputyMembershipRecord (B31)', () => {
beforeAll(async () => {

View file

@ -14,7 +14,7 @@ import { municipalityCatalog } from '@/lib/municipalityCatalog'
import config from '@/payload.config'
import { getAccessibleMunicipalityIds } from '@/utilities/campaignAccess'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
let payload: Payload
@ -42,6 +42,33 @@ describe('municipality catalog seed and per-role access', () => {
expect(salvadorZones.totalDocs).toBe(19)
})
it('agrees with the static catalog on every slug and território', async () => {
// The chip cells ship `{ id, slug }` and resolve name/região from the static
// catalog, so a seeded row the catalog does not know would render no chip at
// all. Only the count was pinned before; this is the agreement that swap needs.
//
// `name` is deliberately not asserted: it is a system field in production,
// but `municipalityPageData.int.spec.ts` renames three rows to search for
// them and never restores them, so the shared test database diverges by
// design. Slug is the identity fixtures claim rows by, and it never moves.
const seeded = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
select: { slug: true, region: true },
})
const bySlug = new Map(seeded.docs.map((municipality) => [municipality.slug, municipality]))
expect(bySlug.size).toBe(municipalityCatalog.length)
for (const entry of municipalityCatalog) {
const row = bySlug.get(entry.slug)
expect(row, entry.slug).toBeDefined()
expect(row?.region, entry.slug).toBe(entry.region)
}
})
it('gives the coordinator unrestricted municipality reads', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const result = await payload.count({
@ -260,17 +287,13 @@ describe('municipality catalog seed and per-role access', () => {
advisors: [advisor.id, candidate.id],
})
fixtures.touchMunicipality(municipality.id)
expect(
assigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
).toEqual([advisor.id, candidate.id])
expect(relationIds(assigned.advisors)).toEqual([advisor.id, candidate.id])
const advisorOnly = await assignMunicipalityAdvisorsRecord(payload, coordinator, {
municipality: municipality.id,
advisors: [advisor.id],
})
expect(
advisorOnly.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
).toEqual([advisor.id])
expect(relationIds(advisorOnly.advisors)).toEqual([advisor.id])
const scope = await getAccessibleMunicipalityIds(
stub<PayloadRequest>({ payload, user: advisor, context: {} }),

View file

@ -7,7 +7,7 @@ import { beforeAll, describe, expect, it } from 'vitest'
import { setMunicipalityAdvisorMembershipRecord } from '@/app/(campaign)/campanha/actions/municipality'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -36,18 +36,14 @@ describe('setMunicipalityAdvisorMembershipRecord (B27)', () => {
assigned: true,
})
fixtures.touchMunicipality(municipality.id)
expect(
assigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
).toContain(advisor.id)
expect(relationIds(assigned.advisors)).toContain(advisor.id)
const removed = await setMunicipalityAdvisorMembershipRecord(payload, actor, {
municipality: municipality.id,
advisor: advisor.id,
assigned: false,
})
expect(
removed.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)) ?? [],
).not.toContain(advisor.id)
expect(relationIds(removed.advisors)).not.toContain(advisor.id)
},
)
@ -63,18 +59,14 @@ describe('setMunicipalityAdvisorMembershipRecord (B27)', () => {
assigned: true,
})
fixtures.touchMunicipality(municipality.id)
expect(
selfAssigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
).toContain(coordinator.id)
expect(relationIds(selfAssigned.advisors)).toContain(coordinator.id)
const candidateAssigned = await setMunicipalityAdvisorMembershipRecord(payload, coordinator, {
municipality: municipality.id,
advisor: candidate.id,
assigned: true,
})
expect(
candidateAssigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
).toContain(candidate.id)
expect(relationIds(candidateAssigned.advisors)).toContain(candidate.id)
})
it('denies advisor and leader actors', async () => {
@ -119,9 +111,7 @@ describe('setMunicipalityAdvisorMembershipRecord (B27)', () => {
advisor: advisor.id,
assigned: true,
})
expect(again.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id))).toEqual([
advisor.id,
])
expect(relationIds(again.advisors)).toEqual([advisor.id])
})
it('rejects once the municipality is at the 10-advisor cap', async () => {

View file

@ -7,7 +7,7 @@ import { beforeAll, describe, expect, it } from 'vitest'
import { setStateDeputyMunicipalitiesBatchRecord } from '@/app/(campaign)/campanha/actions/stateDeputy'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -25,9 +25,7 @@ const stateDeputyIdsOf = async (municipalityID: number): Promise<number[]> => {
select: { stateDeputies: true },
overrideAccess: true,
})
return (municipality.stateDeputies ?? []).map((entry) =>
typeof entry === 'number' ? entry : entry.id,
)
return relationIds(municipality.stateDeputies)
}
describe('setStateDeputyMunicipalitiesBatchRecord (B37)', () => {

View file

@ -22,7 +22,7 @@ import {
} from '@/utilities/supporterPageData'
import { buildSupporterListWhere } from '@/utilities/supporterUi'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
import {
ensureLeasedConsent,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
@ -361,7 +361,7 @@ describe('campaign supporter domain', () => {
phone,
consentAccepted: true,
})
const contactID = typeof created.contact === 'number' ? created.contact : created.contact.id
const contactID = relationId(created.contact)
fixtures.own('supporter', created.id)
fixtures.own('contact', contactID)

View file

@ -323,9 +323,7 @@ describe('campaign cell edit overlay', () => {
ownerId: 1,
ownerName: 'Maria Souza',
municipalityIds: [11],
municipalityIndex: [
{ id: 11, name: MUNICIPALITY_NAME, slug: 'feira-de-santana', region: 'Portal do Sertão' },
],
municipalityIndex: [{ id: 11, slug: 'feira-de-santana' }],
commitAction: async () => ({}),
drawerTitle: 'Editar municípios',
updateErrorMessage: 'Não foi possível atualizar os municípios.',

View file

@ -10,12 +10,10 @@ import {
} from '@/lib/municipalityPortfolio'
const buildIndex = (): MunicipalityPortfolioIndexEntry[] =>
municipalityCatalog.map((entry, index) => ({
id: index + 1,
name: entry.name,
slug: entry.slug,
region: entry.region,
}))
municipalityCatalog.map((entry, index) => ({ id: index + 1, slug: entry.slug }))
const catalogNameForSlug = (slug: string): string =>
municipalityCatalog.find((entry) => entry.slug === slug)?.name ?? ''
/** The index is keyed by slug; `city` lives only in the catalog it is built from. */
const slugsForCities = (cities: readonly string[]): Set<string> =>
@ -42,7 +40,7 @@ describe('municipalityPortfolio', () => {
{
kind: 'municipality',
key: `municipality:${one!.id}`,
label: one!.name,
label: catalogNameForSlug(one!.slug),
municipalityId: one!.id,
slug: one!.slug,
},

View file

@ -3,6 +3,7 @@ import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
type ToastAction = { label: string; onClick: () => void }
@ -19,18 +20,27 @@ vi.mock('sonner', () => ({
}))
/**
* Two municípios that are the WHOLE of their território, so the chip collapses
* and removing it is a multi-id delta — which is what raises the undo toast.
* Real slugs, because the cell resolves name and território from the static
* catalog: Itaparica is the smallest TI (6 municípios), so assigning all of it
* collapses to one chip and removing that chip is a multi-id delta — which is
* what raises the undo toast. Ilhéus stands in for a later, unrelated edit;
* Itabuna is never assigned and is load-bearing, since without a second entry
* of Litoral Sul left over, Ilhéus alone would collapse into a território chip.
*/
const TERRITORY = 'Itaparica'
const TERRITORY_SLUGS = municipalityCatalog
.filter((entry) => entry.region === TERRITORY)
.map((entry) => entry.slug)
const INDEX: MunicipalityPortfolioIndexEntry[] = [
{ id: 1, name: 'Feira de Santana', slug: 'feira-de-santana', region: 'Portal do Sertão' },
{ id: 2, name: 'Serrinha', slug: 'serrinha', region: 'Portal do Sertão' },
{ id: 3, name: 'Ilhéus', slug: 'ilheus', region: 'Litoral Sul' },
// Never assigned, and load-bearing: without a second entry in its território,
// Ilhéus alone would BE Litoral Sul and collapse into a território chip.
{ id: 4, name: 'Itabuna', slug: 'itabuna', region: 'Litoral Sul' },
...TERRITORY_SLUGS.map((slug, position) => ({ id: position + 1, slug })),
{ id: 90, slug: 'ilheus' },
{ id: 91, slug: 'itabuna' },
]
const TERRITORY_IDS = INDEX.slice(0, TERRITORY_SLUGS.length).map((entry) => entry.id)
const ILHEUS_ID = 90
const cell = (municipalityIds: number[]) =>
createElement(MunicipalityPortfolioCell, {
ownerId: 7,
@ -71,17 +81,21 @@ describe('município portfolio cell — optimistic baseline', () => {
* exact match, the row stayed wrong through every later revalidation.
*/
it('undoes a removal against the current set, not the one its render captured', async () => {
const { rerender } = render(cell([1, 2]))
const { rerender } = render(cell(TERRITORY_IDS))
// 1. Drop the território (ids 1 and 2) — a multi-id delta, so it toasts.
fireEvent.click(screen.getByRole('button', { name: 'Remover Portal do Sertão — 2 municípios' }))
// 1. Drop the whole território — a multi-id delta, so it toasts.
fireEvent.click(
screen.getByRole('button', {
name: `Remover ${TERRITORY} — ${TERRITORY_SLUGS.length} municípios`,
}),
)
await waitFor(() => expect(toastActions).toHaveLength(1))
// 2. The write landed and the server re-rendered the row empty.
rerender(cell([]))
// 3. A LATER edit the undo closure knows nothing about.
rerender(cell([3]))
rerender(cell([ILHEUS_ID]))
expect(screen.getByText('Ilhéus')).toBeTruthy()
// 4. Undo. It must ADD the território back, never rewind the row to step 1.
@ -89,7 +103,7 @@ describe('município portfolio cell — optimistic baseline', () => {
toastActions[0]?.onClick()
})
await waitFor(() => expect(screen.getByText('Portal do Sertão')).toBeTruthy())
await waitFor(() => expect(screen.getByText(TERRITORY)).toBeTruthy())
expect(screen.getByText('Ilhéus')).toBeTruthy()
})
})

View file

@ -3,6 +3,7 @@
import { describe, expect, it } from 'vitest'
import { getGlobalCacheTag } from '@/utilities/globals'
import { MUNICIPALITY_CATALOG_CACHE_TAG } from '@/utilities/municipalityRevalidation'
import {
REVALIDATE_POSTS_TAG,
REVALIDATE_PRIVACY_POLICY_CACHE_TAG,
@ -26,6 +27,10 @@ describe('resolveRevalidateTag', () => {
ok: true,
tag: REVALIDATE_PRIVACY_POLICY_CACHE_TAG,
})
expect(resolveRevalidateTag(MUNICIPALITY_CATALOG_CACHE_TAG, null)).toEqual({
ok: true,
tag: MUNICIPALITY_CATALOG_CACHE_TAG,
})
})
it('prefers query tag over body tag', () => {