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:
parent
1badfc85dc
commit
77023db99d
42 changed files with 978 additions and 508 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]+)*$/
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
174
tests/int/campaignAccessRequestMemo.int.spec.ts
Normal file
174
tests/int/campaignAccessRequestMemo.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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: {} }),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue