Merge pull request #477 from fsolla/B176-filtrar-municipios-por-dobradinha-lideranca-e-partido-n

B176 — Filtrar municípios por Dobradinha/Liderança/Partido + coluna Dobradinhas ao assessor
This commit is contained in:
Francisco Solla 2026-08-09 03:41:28 -03:00 committed by GitHub
commit 2270bfb84e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1652 additions and 107 deletions

View file

@ -4,13 +4,14 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-09):** **B176** (Issue #458) — `/campanha/municipios` ganhou três dimensões de filtro na omnibox — **Dobradinha** (nome do contato), **Liderança** (nome do contato) e **Partido** (sigla) — com recortes "Sem dobradinha"/"Sem liderança"/"Sem partido" (precedente "Sem nível"). Params aditivos `stateDeputy`/`leadership`/`party` na URL congelada (sentinelas `sem_dobradinha`/`sem_lideranca`, reuso de `sem_partido`); `buildMunicipalityListWhere` ganhou um catálogo request-scoped fail-closed (`municipalityRelationSets.ts`, lazy: metade liderança só com filtro ativo) que vira as leituras reversa (`leadership.municipalities`) e cruzada (`stateDeputy.party`) em `where` — sem migration, paginação nativa mantida; facets estendidas (`stateDeputyIDs`/`leadershipOptions`/`parties`) e mapa compartilha o catálogo. **Coluna Dobradinhas liberada ao Assessor** (staff-wide): servidor já estava scoped (B37/B157 `reloadStaffActor` + `canUpdateMunicipality`), a mudança foi só de gate de UI (lista/mobile/picker) + catálogo full-staff para a célula (decisão do gate humano 2026-08-09). Extrato do par "sentinela + `exists:false`" em `campaignListUrl` (`NO_PARTY_FILTER_VALUE`, `splitAbsenceFilterValues`, `collapseListWhereOrBranches`) com 5 call sites. E2E: assessor vê/edita a coluna no portfólio; filtros pelas três dimensões; teste de coluna-restrita invertido. Intenção [`filtros-municipios-dobradinha-lideranca.md`](plans/filtros-municipios-dobradinha-lideranca.md); impl [`filtros-municipios-dobradinha-lideranca-impl.md`](plans/filtros-municipios-dobradinha-lideranca-impl.md).
**Recently resolved (2026-08-09):** **B174 reverso** (sem Issue — pedido direto) — a Sollinha (`/campanha/api/ai-chat`) voltou do `@ai-sdk/deepinfra` para o **`@ai-sdk/deepseek`** (API oficial `api.deepseek.com`): o cache-hit do V4-Flash direto custa **$0.0028/M** contra **$0.018/M** na Deep Infra (~6,4× mais barato), e a Sollinha tem prefixo estável (system prompt + tools + histórico) — o hit ratio alto derruba o custo efetivo mesmo com miss ($0.14 vs $0.09) e output ($0.28 vs $0.18) mais caros. Mesmo modelo (`deepseek-v4-flash`), mesmas tools; `@ai-sdk/deepseek` **pinado em `3.0.19`** (não `^3.0.19`, que flutuaria a 3.0.26 e puxaria a ferroviária `provider@4.0.7` duplicada — o lock ficou na `4.0.4/5.0.18` do `ai@7.0.47`, verificado no lockfile). **STT fica na Deep Infra** (B173): `whisper-large-v3` a $0.00045/min de áudio é o mais barato verificado (Groq $0.0019, OpenAI $0.006); `.env.example` agora documenta **as duas chaves** — `DEEPSEEK_API_KEY` (LLM) e `DEEPINFRA_API_KEY` (STT). Aviso da DeepSeek: aumento de preço anunciado ("em breve"). Sem migration, access, UI ou teste novo; smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar.
**Recently resolved (2026-08-09):** **OPS20** (Issue #464) — o e2e de cada worktree agora usa a porta do slot próprio (`3100+slot`), nunca o fallback `3000`: o `.env.test.local` (que o `playwright.config.ts` lê em `playwright.config.ts:14`) passou a carregar `PLAYWRIGHT_BASE_URL` — antes ele só ia para o `.env.local` (que o config **não** lê), então dois `pnpm test:e2e` em worktrees paralelos subiam dois dev servers na porta `3000`. Causa-raiz tratada além do sintoma: os dois blocos env inline do `provision` (que já tinham derivado uma vez) viraram um builder puro único `worktreeEnvFileContents` em `scripts/lib/worktree-env.mjs` que deriva `NEXT_PUBLIC_SITE_URL` + `PLAYWRIGHT_BASE_URL` do mesmo `env.devPort` para **ambos** `.env.local`/`.env.test.local` (paridade estrutural, drift impossível) + unit tests de paridade em `tests/unit/worktree.unit.spec.ts` (falham se o drift voltar). `playwright.config.ts` intocado (a variável certa já existia; só faltava chegar). Sem Docker (fallback) continua degradando ao compartilhado — fora de escopo. Sem migration. Intenção [`porta-e2e-isolada-por-worktree.md`](plans/porta-e2e-isolada-por-worktree.md); impl [`porta-e2e-isolada-por-worktree-impl.md`](plans/porta-e2e-isolada-por-worktree-impl.md).
**Recently resolved (2026-08-08):** **B174** (Issue #449) — a Sollinha (`/campanha/api/ai-chat`) trocou o provider de `@ai-sdk/deepseek` (API oficial `api.deepseek.com`) pelo **`@ai-sdk/deepinfra`** no caminho OpenAI-compatível da Deep Infra — **mesmo modelo** (`deepseek-ai/DeepSeek-V4-Flash`), **mesmas tools** e **mesma chave** que os agentes do time usam (um provedor, uma chave, uma fatura). Swap de 2 linhas no `route.ts`; dep `@ai-sdk/deepinfra@3.0.20` pinada na mesma ferroviária do `ai@7.0.47` (provider 4.0.4 / provider-utils 5.0.18, `pnpm why` confirma cópia única — nada de 4.0.7 duplicado), `@ai-sdk/deepseek` removido (dead dep, knip); `.env.example` `DEEPSEEK_API_KEY` → `DEEPINFRA_API_KEY` (B173 STT/Whisper reusa a mesma chave). Sem migration, access, UI ou teste novo — e2e já mocka o SSE (sem chave real no CI); rollback documentado = `git revert` (restaura provider/baseURL antiga); smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar o flip. Intenção [`sollinha-no-deepinfra.md`](plans/sollinha-no-deepinfra.md); impl [`sollinha-no-deepinfra-impl.md`](plans/sollinha-no-deepinfra-impl.md).
**Recently resolved (2026-08-08):** **B170** (Issue #441, follow-up B169) — o **contrato do colapso de chips virou spec unitário** (`tests/unit/relationChipCellCollapse.unit.spec.tsx`): `RelationChipCell` renderiza 16 chips com `getBoundingClientRect` stubado (4/linha, 20px, gap 6 — a matemática real de `COLLAPSED_CHIP_ROWS`/`CHIP_GAP_PX`) e o spec pina que o slice fica em 12 (3 linhas), "Ver mais…" expande para 16 e "Ver menos" recolhe para 12; casos tudo-cabe (8) e exatamente-cheio (12) não mostram toggle. Antes, os specs das células usavam `measureOverflow: false` ou colapsavam para 1 chip — a mudança de timing do B169 (medição em `useLayoutEffect`) não tinha regressão do comportamento de colapso. **F2 medido e deferido em vez de eliminado**: `PerformanceObserver('layout-shift')` no worktree confirma o resíduo pré-hidratação exatamente como registrado no B169 — load SSR→hidratação com shifts únicos `0.139/0.043/0.257` (fontes: shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips) e a linha da célula indo `97px/42chips → 57px/0chips` numa única transição; **ordenação client-side (RSC) → zero shifts novos**. Eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada pelo dono, que mede layout real de propósito) ou mudança de layout/regra de colapso (anti-goal) — o shift único do primeiro load é aceitável num painel interno staff. Sem migration, dados, access ou mudança visual. Intenção [`estabilizar-lista-dobradinhas-followup.md`](docs/plans/estabilizar-lista-dobradinhas-followup.md); impl [`estabilizar-lista-dobradinhas-followup-impl.md`](docs/plans/estabilizar-lista-dobradinhas-followup-impl.md).
**Recently resolved (2026-08-08):** **`pnpm worktree plan [bag] [--go] [--no-migrate]`** — worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main: **cada invocação cria UM DIFERENTE**, para sessões de planejamento paralelas — com `bag`, branch `plans/plan-issue-<bag>` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-<n>` livre (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`). Prefixo minúsculo `plans/…` nunca colide com um `next` posterior em branch nem em slot (slot do plano = hash do branch, registrado via marcador e bumpado pelos vivos). Mesmo provisionamento isolado do `next` (porta `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test`, migrations; sem Docker degrada ao compartilhado). `kill` cobre; comment das envs distingue `gerado por pnpm worktree plan`. Sem migration. Naming unit-testada.
**Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md).

View file

@ -0,0 +1,103 @@
# Impl: Filtrar municípios por Dobradinha, Liderança e Partido (+ liberar a coluna Dobradinhas ao Assessor)
Status: aprovado
Atualizado em: 2026-08-09
Issue: #458
Intenção: docs/plans/filtros-municipios-dobradinha-lideranca.md
Appetite restante: ~1,5–2 dias eng (herdado)
## Leitura da intenção
- **Outcome:** na omnibox de `/campanha/municipios`, **Dobradinha**, **Liderança** e **Partido (dobradinha)** viram dimensões de filtro (busca por nome/sigla + chips removíveis + recorte "Sem …"), no padrão do Assessor; Multi-valor OR por dimensão; parâmetros novos **aditivos** à URL congelada. A **coluna Dobradinhas** passa a ser vista e editada (inline) pelo **Assessor**, dentro dos municípios que ele administra.
- **O que NÃO negociar:** URL congelada (B18) — mudança aditiva, nunca re-sêmantiza parâmetros existentes; leader lockdown (a página já tem `gate: noLeader`); facets/nomes nunca vazam escopo do ator; `estimatedVotes` fora de alcance (nenhum destes filtros toca estimativa); dados de Dobradinha são staff-wide (registro), mas o recorte do assessor nasce do portfólio administrado.
- **O que reavaliar:** a intenção supõe "liderança necessita leitura reversa" e "partido via facel de leitura cruzada". Confirmei no código: `stateDeputy` **não** guarda `municipalities` (a relação vive só em `municipality.stateDeputies`, `index: true`), então **partido também exige pré-cálculo de conjuntos** (deputados do partido → municípios ligados), não só liderança. O filtro de dobradinha é o único direto (`stateDeputies: { in: [...] }`).
## Abordagem recomendada
```mermaid
flowchart LR
A[municipalityListUrl<br/>novos params: stateDeputy, leadership, party + sentinelas] --> B[municipalityListFilters<br/>multi-params + summary + fast-path toggles]
B --> C[municipalityOmnibox<br/>grupos Dobradinha/Liderança/Partido + Sem…]
D[loadMunicipalityListRelationSets<br/>server-only: conjuntos pré-calculados] --> E[buildMunicipalityListWhere<br/>+ sets no where final]
D --> F[loadMunicipalityListFilterFacets<br/>+ stateDeputyIDs/leadershipIDs/parties]
F --> G[page + MunicipalityFilters<br/>opções rotuladas + maps id→nome]
H[page: libera coluna ao staff<br/>stateDeputyOptions p/ todo staff] --> I[MunicipalityList + MobileCards<br/>coluna sem gate isCampaignUnrestricted]
E --> J[municipalityMapData<br/>mapa compartilha o where]
```
**Opções consideradas:**
- **A — Filtros expressos em `where` com conjuntos pré-calculados** (recomendada): `buildMunicipalityListWhere(state, sets?)` continua pura/síncrona e **fail-closed** quando `leadership`/`party` pedem conjuntos ausentes; conjuntos vêm de um helper server-only (`loadMunicipalityListRelationSets`) usado por página, facets e mapa. Dobradinha (direta) e os "Sem …" combinam sem sair de `where` (`exists: false`, `not_in`).
- **B — Derivar em memória como `class`** (forçar o caminho load-tudo + filtro em JS): simples, mas descarta o paged-by-Payload para 3 dimensões novas (perde paginação nativa e `totalDocs` correto via DB) — pior custo na 435-linha.
- **C — Campo espelho `stateDeputy.municipalities`** (hasMany espelhado mantido pelo mesmo hook que escreve `municipality.stateDeputies`): permitiria joins diretos para partido/liderança, mas é **migração** + hook de espelhamento novo + risco de drift fora do escopo do appetite ("sem migration" no plano da intenção).
**Recomendação:** A — sem migration, mantém o contrato de URL aditivo, mantém a paginação nativa (o `where` continua expresso), facets seguem a regra "valores ainda alcançáveis", e o custo novo é um conjunto pequeno de leituras únicas (sem N+1). C é cara e rejeitada; B é barata mas piora performance global da lista.
**Rejeitadas:** B (introjeção em memória de 3 dimensões numa lista paginada); C (migração + campo espelho; fora do appetite declarado "sem migration").
### Decisões de engenharia
1. **Parâmetros de URL (aditivos)** — `stateDeputy` (IDs numéricos multi, sentinela `sem_dobradinha`), `leadership` (IDs multi, sentinela `sem_lideranca`), `party` (texto multi, sentinela reusa `NO_PARTY_FILTER_VALUE = 'sem_partido'`). Nomes espelham B143 (`stateDeputy`) e `stateDeputyListUrl` (`party`). Estado: `stateDeputies?: number[]`, `leaderships?: number[]`, `parties?: string[]`.
- **Decisão:** extrair o par "sentinela + branch `exists:false`" num helper compartilhado — o comentário de `municipalityListUrl.ts:54-58` já pedia "a third should extract"; hoje há `sem_nivel` e `sem_partido` e este item adiciona `sem_dobradinha` + `sem_lideranca`.
2. **`buildMunicipalityListWhere(state, sets?)`** — assinatura ampliada sem quebrar call sites atuais (map/página passam conjuntos; teste unitário puro continua valendo para o resto). `leadership`→`id: { in: union(named) }` / `id: { not_in: union(all) }`; `party`→`stateDeputies: { in: partyDeputyIDs }` / `stateDeputies: { not_in: allPartyDeputyIDs }`; `stateDeputy` direto. **Guard fail-closed:** se `leaderships` OU `parties` ativos sem `sets`, lança — nenhum caller pode dropar filtro em silêncio.
3. **Novo módulo server-only `municipalityListRelationSets.ts`** — `loadMunicipalityListRelationSets(payload, user, state)` computa 4 conjuntos com `user` + `overrideAccess: false` (scope-honoring): `leadershipMunicipalityIDs` (união dos municípios das lideranças selecionadas), `allLeadershipMunicipalityIDs` (união sobre TODAS as lideranças do escopo; para "Sem liderança"), `partyDeputyIDs` (deputados com `party in selecionados`), `allPartyDeputyIDs` (deputados com qualquer partido; para "Sem partido"). Leitura reversa única de `leadership.municipalities`; `stateDeputy.party` é barato.
4. **Facets** — `loadMunicipalityListFilterFacets` estende o `select` das rows para `stateDeputies`; novo campo `stateDeputyIDs` (união dos vínculos nas rows omit), `leadershipIDs` (leitura reversa sobre os ids das rows omit, reusa `loadMunicipalityLeadershipSummaries`), `parties` (lookup de partido dos deputados presentes nas rows). A regra "cada facet omita a dimensão que ela própria possui" exige que `facetRows` passe a resolver `where` por dimensão via `buildMunicipalityListWhere(state+omit, setsDe(state+omit))` — o memo atual por `JSON.stringify(where)` passa a ser por estado omitido (mesma disciplina, chave nova).
5. **Omnibox** — grupos novos `Dobradinha` (label `Nome (PARTIDO)`, keywords deputado/dobradinha), `Liderança` (nome do contato, keywords lider/lideranca), `Partido` (sigla, keywords partido/psd/…); sons "Sem …" como seeds estáticos (precedente de "Sem nível", não `emptyQueryVisible` — surgem ao digitar "sem"). Chips: `stateDeputy:<id>`, `leadership:<id>`, `party:<value>` com maps id→nome fornecidos pela página; apply/remove via `toggleMunicipalityMultiFilterValue` genérico (novos params entram em `MunicipalityMultiFilterParam`).
6. **Liberação da coluna ao Assessor** — **só UI + catálogo**: o path de escrita já é staff-wide e scoped no servidor (`setStateDeputyMunicipalitiesBatchRecord`/`createMunicipalityStateDeputyRecord` usam `reloadStaffActor` + `canUpdateMunicipality` + `stateDeputies` `canManageCampaignStaffField`, todos staff/portfolio-ready em `actions/stateDeputy.ts`). Mudanças: `MunicipalityList.tsx` (coluna sem o wrapper `isCampaignUnrestricted` no ramo staff; picker idem), `MunicipalityListMobileCards.tsx` (idem), página (`isStaffView ? loadStateDeputyOptions : []`).
- **Catálogo do picker da coluna para o assessor — decisão aberta para o gate humano:** registro de dobradinha é staff-wide por design (`canReadStateDeputy` = staff, diferente de `canReadLeadership` que é scoped). **Recomendação:** manter o catálogo completo do staff para o combobox da célula (o write já limita o município; o picker completo evita o assessor não conseguir adicionar uma dobradinha estadual ao seu município), e o **filtro** nasce scoped por vir das rows do portfólio. **Alternativa:** scoping do catálogo da célula (espelha B155) — mais restritivo, mas gera uma restrição nova que contradiz o registro staff-wide. Ajuste é 1 loader (barato). _(Marcar no gate.)_
7. **Mapa** — `loadMunicipalityMapBundle` passa a computar `sets` (o mapa compartilha o estado da URL da lista; sem isso um recorte por liderança/partido não filtraria o mapa).
8. **Resumo/visita** — `formatMunicipalityActiveFiltersSummary` ganha Dobradinha/Liderança por contagem ("N dobradinhas") e Partido por siglas (valores já são legíveis), coerente com Assessor por contagem; chip label dos sentinelas ("Sem dobradinha" etc.).
### Componentes / mudanças
- **`src/utilities/municipality/municipalityListUrl.ts`**: estado + parse/serialize + `where` (com `sets?`) + paramNames; sentinelas `NO_STATE_DEPUTY_FILTER_VALUE`/`NO_LEADERSHIP_FILTER_VALUE` (+ helper de sentinela compartilhado reusando `NO_PARTY_FILTER_VALUE`).
- **`src/utilities/municipality/municipalityListFilters.ts`**: `MunicipalityMultiFilterParam` + `stateDeputies|leaderships|parties`; toggles/clear já genéricos; summary + `buildMunicipalityFilterOptionHref` (fast-path dos novos params).
- **`src/utilities/municipality/municipalityRelationSets.ts`** (novo, server-only): conjuntos pré-calculados + rótulos (nomes para o omnibox/facets) respeitando escopo.
- **`src/utilities/municipality/municipalityPageData.ts`**: `MunicipalityListFilterFacets` + conjuntos no bundle; página/mapa consomem.
- **`src/utilities/municipality/municipalityOmnibox.ts`**: chips/seeds/apply/remove dos 3 grupos + "Sem …".
- **`src/utilities/municipality/municipalityMapData.ts`**: `sets` no `buildMunicipalityListWhere`.
- **`src/components/campaign/municipality/MunicipalityFilters.tsx`**: props novas (`stateDeputyFilterOptions`, `leadershipFilterOptions`, `partyFilterOptions`, maps id→nome) → seeds/chips.
- **`src/components/campaign/municipality/MunicipalityList.tsx` / `MunicipalityListMobileCards.tsx`**: coluna Dobradinhas liberada ao staff (ramo `isStaffView`).
- **`src/app/(campaign)/campanha/(app)/municipios/page.tsx`**: carrega `stateDeputyOptions` para todo staff; monta as option lists dos novos grupos + maps.
- **Migration:** sem migration (sem schema).
- **Access / Consent:** sem Consent; escopo via `user` + `overrideAccess: false` já existentes; fail-closed no guard de `sets`.
- **UI:** Impeccable **B** — encaixe na omnibox existente (shell `CampaignListOmnibox`, chips, grupos) e no gate de coluna; nenhum componente novo de superfície além dos option lists. Shape→craft (nomes/chips legíveis; grupo "Partido" por sigla; sentinelas com rótulo PT).
### Dados → forma (pergunta 3)
- Forma escolhida: sem dados novos — quantitativo dos recortes já visível; chips citam o mesmo nome das colunas (`stateDeputyDisplayName` / nome do contato da liderança) — única leitura de nomes alimenta coluna e filtro (precedente B155/B157).
- Rejeitadas: % estadual, expor `estimatedVotes`, contagem de seleção além da própria lista.
## Fases verificáveis
1. **URL + where + sets (schema/server)** — sentinela compartilhado, `stateDeputies|leaderships|parties` no estado/parse/serialize, `buildMunicipalityListWhere(state, sets?)` fail-closed, `loadMunicipalityListRelationSets`, facets estendidas, `mapData` com sets. Testes unitários (parse/serialize/where/sentinela/omit-por-dimensão) + int (conjuntos + facets no `teqo_wt…_test`).
2. **UI** — `MunicipalityFilters` + omnibox (grupos, seeds, chips, apply/remove, summary/visita), liberação da coluna (desktop + mobile + picker), página (catalog staff-wide + options). Impeccable B no encaixe.
3. **Gates** — `pnpm gate:fast` na iteração; e2e: inverter o teste "keeps Dobradinha restricted…" → assessor vê/edita a coluna; assessor edita inline dentro do portfólio e é rejeitado fora; adicionar filtros por Dobradinha/Liderança/Partido + "Sem …" (chip, URL, lista). Entregar com `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- **`not_in` com lista vazia** (ex.: "Sem liderança" com zero lideranças no escopo) — semântica SQL a fixar **com teste** (special-case: `in: []` matchea nada; `not_in: []` matchea tudo).
- **Spillover para a página de edição da ficha (`/editar`)** — a intenção cobre só a **coluna** da lista; a ficha fica como está (fora de escopo; notar como follow-up se o gate quiser).
- **Popover de filtro no header das colunas** — decidido "não" no gate; sem trabalho.
- **"Sem organização"/filtros das outras listas** — for a de escopo (candidatos a Items sucessores).
- **Otimização além do padrão** — se os conjuntos exigirem N+1, medir antes (regra da intenção); hoje é 1–3 leituras únicas.
## Riscos e mitigação
- **Custo das facets/leitura reversa no 435-linha (coordenador):** mitigado com leitura única (sem N+1), `index: true` em `municipality.stateDeputies`/`leadership.municipalities`, e reuso do scope da página como semente (mesmo padrão B16+).
- **Regressão no contrato de URL congelado (B18/saved filters):** mudança estritamente aditiva; parse tolerante (IDs inválidos/fora de escopo → simplesmente não matcheiam); bookmarks antigos continuam válidos (params ausentes = sem restrição).
- **Assessor editar/vincular fora do portfólio:** já rejeitado no servidor (B37/B157 `canUpdateMunicipality`); teste de acesso no aceite; UI não esconde a recusa.
- **Vazamento de escopo nas facets:** todas as reads com `overrideAccess: false`; facet de liderança honra `canReadLeadership`.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (3 dimensões + 3 "Sem…" na omnibox; coluna Dobradinhas staff-wide; URL aditiva; assessor scoped no servidor)
- [x] Invariantes AGENTS/engineering-standards (identificadores EN; copy PT; sem migration; sem Consent novo; `overrideAccess: false`)
- [x] Testes de domínio: unit (parse/serialize/where/sentinela/omnibox) + int (sets/facets) + e2e (coluna ao assessor desktop+mobile; edição no portfólio; filtros novos)
## Débitos diferidos (gate capture-review-debts 2026-08-09)
Achados P3 dos reviewers de /simplify, deferidos com gatilho (não poluem a fila):
- **Gatilho (se tocar no resumo de filtros da lista):** consolidar os 3 blocos `formatAbsenceAwareSummary` num formatter tipado de dimensão (contagem/partido) — hoje um helper local cobre; só vale extrair quando uma 4ª dimensão aparecer.
- **Gatilho (se tocar em `parseAdvisorsParam`/canonicalização):** unificar `parseAdvisorsParam` com o núcleo de `canonicalRelationshipWithAbsenceValues` (mesmo loop sem sentinela) — 7 linhas privadas no mesmo arquivo, adiado.
- **Gatilho (se o combobox da coluna Dobradinhas mostrar `Dobradinha #N` para id selecionado fora do facet):** preferir o nome do registro staff-wide antes do placeholder — hoje o cenário não ocorre (facet une selecionados + registro é staff-wide), mantido o precedente `Assessor #N`.

View file

@ -95,9 +95,10 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
// All staff edit the Lideranças column (scoped server-side); the leader
// never reaches this page (`gate: noLeader`).
isStaffView ? getEligibleLeadershipOptions(payload, user) : [],
// B157 — the Dobradinhas column is coordinator + candidate only; the
// catalog serves the avatar-stack display, the tooltip and the search.
canMoveEngagementLevel ? loadStateDeputyOptions(payload, user) : [],
// B157 + B176 — the Dobradinhas column is now staff-wide (was
// coordinator + candidate only); the catalog serves the avatar-stack
// display, the tooltip, the search AND the B176 filter options.
isStaffView ? loadStateDeputyOptions(payload, user) : [],
])
const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor]))
@ -117,19 +118,43 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')),
}
// B176 — filter options for the new omnibox groups, narrowed by the other
// active filters and labeled from the same catalogs the columns use.
const stateDeputyByName = new Map(stateDeputyOptions.map((option) => [option.id, option]))
const selectedStateDeputyIDs = new Set(
(state.stateDeputies ?? []).filter((value): value is number => typeof value === 'number'),
)
const globalFilterOptions = {
stateDeputy: filterFacets.stateDeputyIDs
.flatMap((id) => {
const option = stateDeputyByName.get(id)
if (option) return [{ value: String(id), label: option.name }]
return selectedStateDeputyIDs.has(id)
? [{ value: String(id), label: `Dobradinha #${id}` }]
: []
})
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')),
leadership: filterFacets.leadershipOptions
.map((leadership) => ({ value: String(leadership.id), label: leadership.name }))
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')),
party: filterFacets.parties.map((party) => ({ value: party, label: party })),
}
const filters = (
<MunicipalityFilters
state={state}
showStaffFilters={isStaffView}
regionFilterOptions={columnFilterOptions.region}
advisorFilterOptions={columnFilterOptions.advisor}
stateDeputyFilterOptions={globalFilterOptions.stateDeputy}
leadershipFilterOptions={globalFilterOptions.leadership}
partyFilterOptions={globalFilterOptions.party}
slugFilterValues={columnFilterOptions.name}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={municipalityListPickerColumns({
isStaffView,
isCampaignUnrestricted: canMoveEngagementLevel,
})}
/>
}

View file

@ -30,17 +30,36 @@ type MunicipalityFiltersProps = {
regionFilterOptions: MunicipalityFilterOption[]
/** Advisor options already narrowed by the other active filters. */
advisorFilterOptions: MunicipalityFilterOption[]
/** B176 — Dobradinha, Liderança and Partido options (named, narrowed). */
stateDeputyFilterOptions: MunicipalityFilterOption[]
leadershipFilterOptions: MunicipalityFilterOption[]
partyFilterOptions: MunicipalityFilterOption[]
/** Facet slugs (labeled from the catalog on the client). */
slugFilterValues?: readonly string[]
/** Beside the omnibox (B137): column picker, save bookmark, … */
trailing?: ReactNode
}
/** Chip-label lookup for a numeric-id filter dimension (advisor/dobradinha/liderança). */
const labelsByIdFromOptions = (
options: readonly MunicipalityFilterOption[],
): Map<number, string> => {
const map = new Map<number, string>()
for (const option of options) {
const id = Number(option.value)
if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label)
}
return map
}
export const MunicipalityFilters = ({
state,
showStaffFilters,
regionFilterOptions,
advisorFilterOptions,
stateDeputyFilterOptions = [],
leadershipFilterOptions = [],
partyFilterOptions = [],
slugFilterValues = [],
trailing,
}: MunicipalityFiltersProps) => {
@ -58,14 +77,20 @@ export const MunicipalityFilters = ({
[slugFilterValues],
)
const advisorLabelsById = useMemo(() => {
const map = new Map<number, string>()
for (const option of advisorFilterOptions) {
const id = Number(option.value)
if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label)
}
return map
}, [advisorFilterOptions])
const advisorLabelsById = useMemo(
() => labelsByIdFromOptions(advisorFilterOptions),
[advisorFilterOptions],
)
const stateDeputyLabelsById = useMemo(
() => labelsByIdFromOptions(stateDeputyFilterOptions),
[stateDeputyFilterOptions],
)
const leadershipLabelsById = useMemo(
() => labelsByIdFromOptions(leadershipFilterOptions),
[leadershipFilterOptions],
)
const chips = useMemo(
() =>
@ -74,8 +99,17 @@ export const MunicipalityFilters = ({
scenario,
showStaffFilters,
advisorLabelsById,
stateDeputyLabelsById,
leadershipLabelsById,
}),
[state, scenario, showStaffFilters, advisorLabelsById],
[
state,
scenario,
showStaffFilters,
advisorLabelsById,
stateDeputyLabelsById,
leadershipLabelsById,
],
)
const suggestionSeeds = useMemo(
@ -85,8 +119,19 @@ export const MunicipalityFilters = ({
regionFilterOptions,
advisorFilterOptions,
slugFilterOptions,
stateDeputyFilterOptions,
leadershipFilterOptions,
partyFilterOptions,
}),
[showStaffFilters, regionFilterOptions, advisorFilterOptions, slugFilterOptions],
[
showStaffFilters,
regionFilterOptions,
advisorFilterOptions,
slugFilterOptions,
stateDeputyFilterOptions,
leadershipFilterOptions,
partyFilterOptions,
],
)
const suggestions = useMemo(

View file

@ -484,37 +484,36 @@ const municipalityListColumns = ({
/>
),
},
...(isCampaignUnrestricted
? ([
{
id: 'stateDeputies',
label: municipalityColumnLabels.stateDeputies,
head: (
<CampaignTableHead
description={municipalityColumnDescriptions.stateDeputies}
className={responsiveColumnClassName.stateDeputies}
>
{municipalityColumnLabels.stateDeputies}
</CampaignTableHead>
),
cellClassName: cn(
responsiveColumnClassName.stateDeputies,
'max-w-56 whitespace-normal',
),
cell: (municipality) => (
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
stateDeputyIDs={municipality.stateDeputyIDs}
options={stateDeputyOptions}
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="popover"
/>
),
},
] satisfies Array<MunicipalityColumn>)
: []),
{
// B176 — staff-wide since 2026-08-09: the edit is scoped to the
// actor's administered municípios server-side (B37/B157), so the
// advisor gets the same inline control the Lideranças column has.
id: 'stateDeputies',
label: municipalityColumnLabels.stateDeputies,
head: (
<CampaignTableHead
description={municipalityColumnDescriptions.stateDeputies}
className={responsiveColumnClassName.stateDeputies}
>
{municipalityColumnLabels.stateDeputies}
</CampaignTableHead>
),
cellClassName: cn(
responsiveColumnClassName.stateDeputies,
'max-w-56 whitespace-normal',
),
cell: (municipality) => (
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
stateDeputyIDs={municipality.stateDeputyIDs}
options={stateDeputyOptions}
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="popover"
/>
),
},
{
id: 'goalCoverage',
@ -592,11 +591,7 @@ const municipalityListColumns = ({
export const municipalityListPickerColumns = ({
isStaffView,
isCampaignUnrestricted,
}: Pick<
MunicipalityListProps,
'isStaffView' | 'isCampaignUnrestricted'
>): CampaignColumnPickerColumn[] => {
}: Pick<MunicipalityListProps, 'isStaffView'>): CampaignColumnPickerColumn[] => {
const base: CampaignColumnPickerColumn[] = [
{ id: 'name', label: municipalityColumnLabels.name, mandatory: true },
{ id: 'votos', label: municipalityColumnLabels.votos },
@ -611,9 +606,7 @@ export const municipalityListPickerColumns = ({
{ id: 'advisors', label: municipalityColumnLabels.advisors },
{ id: 'trend', label: municipalityColumnLabels.trend },
{ id: 'leaderships', label: municipalityColumnLabels.leaderships },
...(isCampaignUnrestricted
? [{ id: 'stateDeputies', label: municipalityColumnLabels.stateDeputies }]
: []),
{ id: 'stateDeputies', label: municipalityColumnLabels.stateDeputies },
{ id: 'goalCoverage', label: municipalityColumnLabels.goalCoverage },
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
]

View file

@ -234,7 +234,9 @@ export const MunicipalityListMobileCards = ({
/>
</dd>
</div>
{isCampaignUnrestricted ? (
{/* B176 — staff-wide since 2026-08-09; the write stays scoped to
the actor's administered municípios (B37/B157). */}
{isStaffView ? (
<div className="col-span-2">
<dt className="text-muted-foreground">Dobradinhas</dt>
<dd>

View file

@ -1,5 +1,41 @@
import type { Where } from 'payload'
export type RawSearchParams = Record<string, string | string[] | undefined>
/**
* Sentinel for the "Sem partido" filter row — `party` is a free-text optional
* field (no closed enum to validate a real value against), so this constant
* is the only reserved token; a dobradinha whose actual party name collided
* with it would be indistinguishable, an accepted precedent-level risk (the
* curated field spans a couple dozen Brazilian party acronyms). Lives here so
* every list that filters municipalities by a dobradinha's party — the
* dobradinhas list and the municípios list — shares ONE sentinel value.
*/
export const NO_PARTY_FILTER_VALUE = 'sem_partido'
/**
* Splits a multi-value filter into its named values and whether the "absence"
* sentinel is selected. Every list filter with a selectable absence (level's
* "Sem nível", party's "Sem partido", B176's "Sem dobradinha" / "Sem
* liderança") repeats this pair — extract here instead of a per-list copy.
*/
export const splitAbsenceFilterValues = <Named>(
values: readonly Named[],
isAbsence: (value: Named) => boolean,
): { named: Named[]; hasAbsence: boolean } => {
const named: Named[] = []
let hasAbsence = false
for (const value of values) {
if (isAbsence(value)) hasAbsence = true
else named.push(value)
}
return { named, hasAbsence }
}
/** Collapses 0..n OR branches: none → undefined, one → the branch, many → `{ or }`. */
export const collapseListWhereOrBranches = (branches: readonly Where[]): Where | undefined =>
branches.length > 1 ? { or: [...branches] } : branches[0]
export const firstValue = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value

View file

@ -6,7 +6,7 @@
import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { EMPTY_ENGAGEMENT_LEVEL_LABEL, formatEngagementLevelLabel } from '@/lib/engagementLevel'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
import { NO_PARTY_FILTER_VALUE, truncatedNamesLabel } from '@/utilities/campaignListUrl'
import {
municipalityListCoverageLabels,
municipalityPriorityLabels,
@ -16,9 +16,13 @@ import {
} from '@/utilities/municipality/municipalityLabels'
import {
buildMunicipalityListHref,
canonicalPartyValues,
canonicalRelationshipWithAbsenceValues,
municipalityListLevelFilterValues,
municipalityListStateToRawParams,
NO_LEADERSHIP_FILTER_VALUE,
NO_LEVEL_FILTER_VALUE,
NO_STATE_DEPUTY_FILTER_VALUE,
parseMunicipalityListParams,
serializeCanonicalMunicipalityListSearchParams,
type MunicipalityListLevelFilterValue,
@ -151,6 +155,9 @@ export type MunicipalityMultiFilterParam =
| 'trend'
| 'class'
| 'level'
| 'stateDeputy'
| 'leadership'
| 'party'
export const getMunicipalityMultiFilterValues = (
state: MunicipalityListState,
@ -161,6 +168,9 @@ export const getMunicipalityMultiFilterValues = (
if (param === 'trend') return state.trends ?? []
if (param === 'class') return state.classes ?? []
if (param === 'level') return state.levels ?? []
if (param === 'stateDeputy') return (state.stateDeputies ?? []).map(String)
if (param === 'leadership') return (state.leaderships ?? []).map(String)
if (param === 'party') return state.parties ?? []
return (state.advisors ?? []).map(String)
}
@ -299,6 +309,21 @@ export const buildMunicipalityFilterOptionHref = (
const levels = toggled.filter(isEngagementLevelFilterValue)
if (levels.length && levels.length < engagementLevelFilterCount) next.levels = levels
else delete next.levels
} else if (param === 'stateDeputy') {
const stateDeputies = canonicalRelationshipWithAbsenceValues(
toggled,
NO_STATE_DEPUTY_FILTER_VALUE,
)
if (stateDeputies.length) next.stateDeputies = stateDeputies
else delete next.stateDeputies
} else if (param === 'leadership') {
const leaderships = canonicalRelationshipWithAbsenceValues(toggled, NO_LEADERSHIP_FILTER_VALUE)
if (leaderships.length) next.leaderships = leaderships
else delete next.leaderships
} else if (param === 'party') {
const parties = canonicalPartyValues(toggled)
if (parties.length) next.parties = parties
else delete next.parties
}
const query = serializeCanonicalMunicipalityListSearchParams(next).toString()
@ -366,11 +391,57 @@ export const formatMunicipalityActiveFiltersSummary = (
.join(', ')}`,
)
}
if (state.stateDeputies?.length) {
const count = state.stateDeputies.filter((value) => typeof value === 'number').length
const phrase = count === 1 ? '1 dobradinha' : `${count} dobradinhas`
parts.push(
formatAbsenceAwareSummary(
count ? phrase : 'Sem dobradinha',
state.stateDeputies.includes(NO_STATE_DEPUTY_FILTER_VALUE),
'Sem dobradinha',
),
)
}
if (state.leaderships?.length) {
const count = state.leaderships.filter((value) => typeof value === 'number').length
const phrase = count === 1 ? '1 liderança' : `${count} lideranças`
parts.push(
formatAbsenceAwareSummary(
count ? phrase : 'Sem liderança',
state.leaderships.includes(NO_LEADERSHIP_FILTER_VALUE),
'Sem liderança',
),
)
}
if (state.parties?.length) {
const named = state.parties.filter((party) => party !== NO_PARTY_FILTER_VALUE)
parts.push(
formatAbsenceAwareSummary(
named.length ? `Partido ${named.join(', ')}` : 'Sem partido',
state.parties.includes(NO_PARTY_FILTER_VALUE),
'Sem partido',
),
)
}
if (state.q) parts.push(`Busca "${state.q}"`)
return parts.length ? parts.join(' · ') : null
}
/**
* B176 — "N dobradinhas" / "Partido PT, PSD" with the selectable absence: when
* the sentinel is ALSO selected it rides along as "… + sem X"; an absence-only
* selection reads as the bare absence label.
*/
const formatAbsenceAwareSummary = (
basePhrase: string,
hasAbsence: boolean,
absenceLabel: string,
): string => {
if (!hasAbsence) return basePhrase
return basePhrase === absenceLabel ? basePhrase : `${basePhrase} + ${absenceLabel.toLowerCase()}`
}
const MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH = 80
export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): string | null => {

View file

@ -12,11 +12,14 @@ import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
import {
allParamValues,
buildListHref,
collapseListWhereOrBranches,
createSortToggleHref,
firstValue,
NO_PARTY_FILTER_VALUE,
normalizedText,
parseExhaustiveEnumParam,
resolveListUrl,
splitAbsenceFilterValues,
strictDecimalInteger,
type RawSearchParams as CampaignListRawSearchParams,
} from '@/utilities/campaignListUrl'
@ -65,6 +68,26 @@ export const municipalityListLevelFilterValues: readonly MunicipalityListLevelFi
NO_LEVEL_FILTER_VALUE,
]
/**
* Sentinel for the "Sem dobradinha" filter row: the municipality↔dobradinha
* link is a hasMany relationship (`municipality.stateDeputies`), so absence is
* `stateDeputies: { exists: false }` — the same pair (reserved token + absence
* branch) as `NO_LEVEL_FILTER_VALUE` and `NO_PARTY_FILTER_VALUE`.
*/
export const NO_STATE_DEPUTY_FILTER_VALUE = 'sem_dobradinha'
/**
* Sentinel for the "Sem liderança" filter row. The link is reverse
* (`leadership.municipalities`), so absence is expressed as "municipality not
* linked to ANY leadership in the actor's scope" — see
* `MunicipalityListRelationCatalog.allLeadershipMunicipalityIDs`.
*/
export const NO_LEADERSHIP_FILTER_VALUE = 'sem_lideranca'
type MunicipalityListStateDeputyFilterValue = number | typeof NO_STATE_DEPUTY_FILTER_VALUE
type MunicipalityListLeadershipFilterValue = number | typeof NO_LEADERSHIP_FILTER_VALUE
/**
* E9 allocation queue: the list opens on the decision it exists to serve —
* where the goal is least covered by auditable commitments (biggest deficit
@ -145,12 +168,47 @@ export type MunicipalityListState = {
* Never holds the full set (same "todos" canonicalization as `trends`).
*/
levels?: MunicipalityListLevelFilterValue[]
/**
* Multi-select (OR) B176 — stateDeputy ids linked to the município, plus the
* "Sem dobradinha" sentinel. Direct relationship (`municipality.stateDeputies`),
* so unlike `leaderships`/`parties` it needs no precomputed catalog.
*/
stateDeputies?: MunicipalityListStateDeputyFilterValue[]
/**
* Multi-select (OR) B176 — leadership ids acting in the município, plus the
* "Sem liderança" sentinel. Reverse relationship (`leadership.municipalities`),
* so `buildMunicipalityListWhere` derives the municipality-id set from the
* request-scoped `MunicipalityListRelationCatalog`.
*/
leaderships?: MunicipalityListLeadershipFilterValue[]
/**
* Multi-select (OR) B176 — party names of the município's dobradinhas, plus
* `NO_PARTY_FILTER_VALUE`. Needs the party→deputy-id lookup in the catalog.
*/
parties?: string[]
/** Candidate number for the map comparison mode (does not filter the list). */
compare?: number
sort?: MunicipalityListSortKey
dir?: MunicipalityListSortDirection
}
/**
* B176 — the request-scoped relation sets `buildMunicipalityListWhere` needs to
* turn the reverse (`leadership.municipalities`) and cross (`stateDeputy.party`)
* filters into municipality-level `where` clauses. State-independent, so a page
* or the map loads it ONCE and the pure where-builder derives per-state branches.
*/
export type MunicipalityListRelationCatalog = {
/** Scoped leadership id → municipality ids it acts in (the actor's read scope). */
leadershipMunicipalityIDsByLeadership: ReadonlyMap<number, readonly number[]>
/** Municipality ids linked to ANY scoped leadership — for "Sem liderança". */
allLeadershipMunicipalityIDs: ReadonlySet<number>
/** Party name → stateDeputy ids carrying it — for named party filters. */
stateDeputyIDsByParty: ReadonlyMap<string, readonly number[]>
/** StateDeputy ids that carry any party — for "Sem partido". */
allPartyStateDeputyIDs: ReadonlySet<number>
}
export type MunicipalityListSearchParams = CampaignListRawSearchParams
const municipalityListParamNames = [
@ -163,6 +221,9 @@ const municipalityListParamNames = [
'trend',
'class',
'level',
'stateDeputy',
'leadership',
'party',
'compare',
'sort',
'dir',
@ -265,6 +326,67 @@ const parseAdvisorsParam = (raw: string | string[] | undefined): number[] => {
return advisors.sort((left, right) => left - right)
}
/**
* B176 — a relationship multi-select whose absence is a reserved sentinel token
* (`sem_dobradinha` / `sem_lideranca`): integer ids sort ascending and the
* sentinel rides last, so one value set canonicalizes to one URL. Also the
* canonicalizer the fast-path toggle (`buildMunicipalityFilterOptionHref`) uses.
*/
export const canonicalRelationshipWithAbsenceValues = <Sentinel extends string>(
raw: string | string[] | undefined,
sentinel: Sentinel,
): Array<number | Sentinel> => {
const ids: number[] = []
const seen = new Set<number>()
let hasAbsence = false
for (const token of allParamValues(raw)) {
if (token === sentinel) {
hasAbsence = true
continue
}
const id = strictDecimalInteger(token)
if (id === undefined || seen.has(id)) continue
seen.add(id)
ids.push(id)
}
return [...ids.sort((left, right) => left - right), ...(hasAbsence ? [sentinel] : [])]
}
const parseStateDeputiesParam = (
raw: string | string[] | undefined,
): MunicipalityListStateDeputyFilterValue[] =>
canonicalRelationshipWithAbsenceValues(raw, NO_STATE_DEPUTY_FILTER_VALUE)
const parseLeadershipsParam = (
raw: string | string[] | undefined,
): MunicipalityListLeadershipFilterValue[] =>
canonicalRelationshipWithAbsenceValues(raw, NO_LEADERSHIP_FILTER_VALUE)
/**
* B176 — party values canonicalize the same way the relationship dims do:
* named parties keep first-seen order (deduped, ≤32 chars) and the "Sem
* partido" sentinel rides last, so one value set maps to one URL. Shared by
* the URL parser and the fast-path toggle so both writers agree.
*/
export const canonicalPartyValues = (values: readonly string[]): string[] => {
const named: string[] = []
const seen = new Set<string>()
let hasAbsence = false
for (const value of values) {
if (value === NO_PARTY_FILTER_VALUE) {
hasAbsence = true
continue
}
if (seen.has(value) || value.length > 32) continue
seen.add(value)
named.push(value)
}
return [...named, ...(hasAbsence ? [NO_PARTY_FILTER_VALUE] : [])]
}
const parsePartiesParam = (raw: string | string[] | undefined): string[] =>
canonicalPartyValues(allParamValues(raw))
const politicalTrendStatusSet = new Set<string>(Object.keys(politicalTrendLabels))
const territorialClassSet = new Set<string>(Object.keys(territorialClassLabels))
const engagementLevelFilterSet = new Set<string>(municipalityListLevelFilterValues)
@ -283,6 +405,9 @@ export const municipalityListStateToRawParams = (
trend: state.trends,
class: state.classes,
level: state.levels,
stateDeputy: state.stateDeputies?.map(String),
leadership: state.leaderships?.map(String),
party: state.parties,
compare: state.compare === undefined ? undefined : String(state.compare),
sort: state.sort,
dir: state.dir,
@ -310,6 +435,9 @@ export const parseMunicipalityListParams = (
params.level,
engagementLevelFilterSet,
)
const stateDeputies = parseStateDeputiesParam(params.stateDeputy)
const leaderships = parseLeadershipsParam(params.leadership)
const parties = parsePartiesParam(params.party)
const rawCompare = strictDecimalInteger(firstValue(params.compare))
const rawSort = firstValue(params.sort) as MunicipalityListSortKey | undefined
const sort = rawSort && municipalityListSortKeySet.has(rawSort) ? rawSort : undefined
@ -329,13 +457,19 @@ export const parseMunicipalityListParams = (
...(trends.length ? { trends } : {}),
...(classes.length ? { classes } : {}),
...(levels.length ? { levels } : {}),
...(stateDeputies.length ? { stateDeputies } : {}),
...(leaderships.length ? { leaderships } : {}),
...(parties.length ? { parties } : {}),
...(rawCompare && rawCompare <= 99999 ? { compare: rawCompare } : {}),
...(sort ? { sort } : {}),
...(dir ? { dir } : {}),
}
}
export const buildMunicipalityListWhere = (state: MunicipalityListState): Where => {
export const buildMunicipalityListWhere = (
state: MunicipalityListState,
relationCatalog?: MunicipalityListRelationCatalog,
): Where => {
const filters: Where[] = []
const searchedZone = strictDecimalInteger(state.q)
@ -359,16 +493,77 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
if (state.levels?.length) {
// "Sem nível" is absence, which no `in` can express — it rides along as an
// OR branch so "N0 ou sem nível" (the triage view) is one query.
const selectedLevels = state.levels.filter(
(level): level is EngagementLevel => level !== NO_LEVEL_FILTER_VALUE,
const { named: selectedLevels, hasAbsence } = splitAbsenceFilterValues(
state.levels,
(level) => level === NO_LEVEL_FILTER_VALUE,
)
const levelFilters: Where[] = []
if (selectedLevels.length) levelFilters.push({ engagementLevel: { in: selectedLevels } })
if (state.levels.includes(NO_LEVEL_FILTER_VALUE)) {
levelFilters.push({ engagementLevel: { exists: false } })
if (hasAbsence) levelFilters.push({ engagementLevel: { exists: false } })
const branch = collapseListWhereOrBranches(levelFilters)
if (branch) filters.push(branch)
}
// --- B176: dobradinha (direct relationship) --------------------------------
if (state.stateDeputies?.length) {
const { named: selectedStateDeputies, hasAbsence } = splitAbsenceFilterValues(
state.stateDeputies,
(value) => value === NO_STATE_DEPUTY_FILTER_VALUE,
)
const stateDeputyFilters: Where[] = []
if (selectedStateDeputies.length) {
stateDeputyFilters.push({ stateDeputies: { in: selectedStateDeputies } })
}
const [onlyFilter, ...restFilters] = levelFilters
filters.push(onlyFilter && restFilters.length === 0 ? onlyFilter : { or: levelFilters })
if (hasAbsence) stateDeputyFilters.push({ stateDeputies: { exists: false } })
const branch = collapseListWhereOrBranches(stateDeputyFilters)
if (branch) filters.push(branch)
}
// --- B176: leadership (reverse relation — needs the catalog) --------------
if (state.leaderships?.length) {
assertMunicipalityRelationCatalog(state.leaderships, relationCatalog)
const { named: selectedLeaderships, hasAbsence } = splitAbsenceFilterValues(
state.leaderships,
(value) => value === NO_LEADERSHIP_FILTER_VALUE,
)
const leadershipFilters: Where[] = []
if (selectedLeaderships.length) {
leadershipFilters.push({
id: {
in: unionSortedIDsFrom(
relationCatalog.leadershipMunicipalityIDsByLeadership,
selectedLeaderships.filter((id): id is number => typeof id === 'number'),
),
},
})
}
if (hasAbsence) {
leadershipFilters.push({ id: { not_in: [...relationCatalog.allLeadershipMunicipalityIDs] } })
}
const branch = collapseListWhereOrBranches(leadershipFilters)
if (branch) filters.push(branch)
}
// --- B176: partido da dobradinha (cross read — needs the catalog) ---------
if (state.parties?.length) {
assertMunicipalityRelationCatalog(state.parties, relationCatalog)
const { named: namedParties, hasAbsence } = splitAbsenceFilterValues(
state.parties,
(party) => party === NO_PARTY_FILTER_VALUE,
)
const partyFilters: Where[] = []
if (namedParties.length) {
partyFilters.push({
stateDeputies: {
in: unionSortedIDsFrom(relationCatalog.stateDeputyIDsByParty, namedParties),
},
})
}
if (hasAbsence) {
partyFilters.push({ stateDeputies: { not_in: [...relationCatalog.allPartyStateDeputyIDs] } })
}
const branch = collapseListWhereOrBranches(partyFilters)
if (branch) filters.push(branch)
}
// `state.classes` is absent on purpose: the class is derived from the TSE
// artifact, not stored, so it can't be a Payload constraint. `municipalityPageData`
@ -377,6 +572,29 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
return filters.length ? { and: filters } : {}
}
/**
* Fail-closed: the reverse (`leadership`) and cross (`party`) filters can only
* become a `where` with the request-scoped catalog. Dropping the filter
* silently would hand back a WIDER recorte than the URL asked for.
*/
function assertMunicipalityRelationCatalog(
values: readonly unknown[],
relationCatalog: MunicipalityListRelationCatalog | undefined,
): asserts relationCatalog is MunicipalityListRelationCatalog {
if (!relationCatalog) {
throw new Error(
`buildMunicipalityListWhere: missing relation catalog for the filter ${JSON.stringify(values)}`,
)
}
}
/** Union of the id lists behind each key, sorted ascending — the OR of a relation dimension. */
const unionSortedIDsFrom = <Key>(
byKey: ReadonlyMap<Key, readonly number[]>,
keys: readonly Key[],
): number[] =>
[...new Set(keys.flatMap((key) => byKey.get(key) ?? []))].sort((left, right) => left - right)
/**
* Serializes a state that is ALREADY canonical (came out of
* `parseMunicipalityListParams`, or was derived from a canonical state by a
@ -404,6 +622,13 @@ export const serializeCanonicalMunicipalityListSearchParams = (
params.append('class', territorialClass)
}
for (const level of canonicalState.levels ?? []) params.append('level', level)
for (const stateDeputy of canonicalState.stateDeputies ?? []) {
params.append('stateDeputy', String(stateDeputy))
}
for (const leadership of canonicalState.leaderships ?? []) {
params.append('leadership', String(leadership))
}
for (const party of canonicalState.parties ?? []) params.append('party', party)
if (canonicalState.compare) params.set('compare', String(canonicalState.compare))
// Omit the default pair (staff: deficit+desc). Keep `sort` whenever the pair
// is non-default so `dir` is never orphaned (e.g. votos+asc → sort=votos&dir=asc).

View file

@ -41,6 +41,7 @@ import {
mapKeyForMunicipality,
} from '@/utilities/municipality/municipalityMapNavigation'
import { projectedValidVotes } from '@/utilities/municipality/municipalityPotential'
import { loadMunicipalityListRelationCatalog } from '@/utilities/municipality/municipalityRelationSets'
import {
computeMunicipalityTerritorialClass,
type MunicipalityTerritorialClass,
@ -254,7 +255,15 @@ export const loadMunicipalityMapBundle = async (
if (user.role === 'leader') return null
const state = parseMunicipalityListParams(searchParams)
const scope = await loadMunicipalityScope(payload, user, buildMunicipalityListWhere(state))
// B176 — the map shares the list URL, so a leadership/party recorte must
// carry its relation catalog into the very same `where` the list uses. Lazy
// per active filter, so the dashboard landing page pays no extra reads.
const relationCatalog = await loadMunicipalityListRelationCatalog(payload, user, state)
const scope = await loadMunicipalityScope(
payload,
user,
buildMunicipalityListWhere(state, relationCatalog),
)
const municipalities = scopeMunicipalitiesFromDocs(scope.municipalities)
if (municipalities.length === 0) return null

View file

@ -15,6 +15,7 @@ import {
type VoteEstimateScenario,
} from '@/lib/voteEstimate'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
import {
municipalityListCoverageLabels,
municipalityPriorityLabels,
@ -31,6 +32,8 @@ import {
import {
isDefaultMunicipalityListSort,
municipalityListSortOptions,
NO_LEADERSHIP_FILTER_VALUE,
NO_STATE_DEPUTY_FILTER_VALUE,
resolveMunicipalityListSort,
type MunicipalityListState,
} from '@/utilities/municipality/municipalityListUrl'
@ -56,6 +59,9 @@ const MULTI_PREFIXES: Array<{ prefix: string; param: MunicipalityMultiFilterPara
{ prefix: 'trend:', param: 'trend' },
{ prefix: 'class:', param: 'class' },
{ prefix: 'level:', param: 'level' },
{ prefix: 'stateDeputy:', param: 'stateDeputy' },
{ prefix: 'leadership:', param: 'leadership' },
{ prefix: 'party:', param: 'party' },
]
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
@ -84,11 +90,15 @@ export const buildMunicipalityOmniboxChips = ({
scenario,
showStaffFilters,
advisorLabelsById,
stateDeputyLabelsById,
leadershipLabelsById,
}: {
state: MunicipalityListState
scenario: VoteEstimateScenario
showStaffFilters: boolean
advisorLabelsById: ReadonlyMap<number, string>
stateDeputyLabelsById: ReadonlyMap<number, string>
leadershipLabelsById: ReadonlyMap<number, string>
}): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
@ -145,6 +155,40 @@ export const buildMunicipalityOmniboxChips = ({
})
}
for (const stateDeputy of state.stateDeputies ?? []) {
chips.push({
id: `stateDeputy:${stateDeputy}`,
label:
stateDeputy === NO_STATE_DEPUTY_FILTER_VALUE
? 'Dobradinha: Sem dobradinha'
: chipLabel(
'Dobradinha',
stateDeputyLabelsById.get(stateDeputy) ?? `Dobradinha #${stateDeputy}`,
),
})
}
for (const leadership of state.leaderships ?? []) {
chips.push({
id: `leadership:${leadership}`,
label:
leadership === NO_LEADERSHIP_FILTER_VALUE
? 'Liderança: Sem liderança'
: chipLabel(
'Liderança',
leadershipLabelsById.get(leadership) ?? `Liderança #${leadership}`,
),
})
}
for (const party of state.parties ?? []) {
chips.push({
id: `party:${party}`,
label:
party === NO_PARTY_FILTER_VALUE ? 'Partido: Sem partido' : chipLabel('Partido', party),
})
}
if (scenario !== DEFAULT_VOTE_ESTIMATE_SCENARIO) {
chips.push({
id: 'scenario',
@ -173,11 +217,17 @@ export const buildMunicipalityOmniboxSuggestionSeeds = ({
regionFilterOptions,
advisorFilterOptions,
slugFilterOptions,
stateDeputyFilterOptions,
leadershipFilterOptions,
partyFilterOptions,
}: {
showStaffFilters: boolean
regionFilterOptions: readonly MunicipalityFilterOption[]
advisorFilterOptions: readonly MunicipalityFilterOption[]
slugFilterOptions: readonly MunicipalityFilterOption[]
stateDeputyFilterOptions: readonly MunicipalityFilterOption[]
leadershipFilterOptions: readonly MunicipalityFilterOption[]
partyFilterOptions: readonly MunicipalityFilterOption[]
}): SuggestionSeed[] => {
const seeds: SuggestionSeed[] = []
@ -229,6 +279,68 @@ export const buildMunicipalityOmniboxSuggestionSeeds = ({
)
}
for (const option of stateDeputyFilterOptions) {
seeds.push(
seedOf({
id: `stateDeputy:${option.value}`,
group: 'Dobradinha',
label: option.label,
keywords: ['dobradinha', 'deputado', 'deputada'],
}),
)
}
// "Sem dobradinha" is the triage of this dimension — same static seed as
// "Sem nível" (revealed while typing, never `emptyQueryVisible`).
seeds.push(
seedOf({
id: `stateDeputy:${NO_STATE_DEPUTY_FILTER_VALUE}`,
group: 'Dobradinha',
label: 'Sem dobradinha',
keywords: ['sem', 'dobradinha'],
}),
)
for (const option of leadershipFilterOptions) {
seeds.push(
seedOf({
id: `leadership:${option.value}`,
group: 'Liderança',
label: option.label,
keywords: ['lideranca', 'lider', 'liderança'],
}),
)
}
seeds.push(
seedOf({
id: `leadership:${NO_LEADERSHIP_FILTER_VALUE}`,
group: 'Liderança',
label: 'Sem liderança',
keywords: ['sem', 'lideranca', 'lider', 'liderança'],
}),
)
for (const option of partyFilterOptions) {
seeds.push(
seedOf({
id: `party:${option.value}`,
group: 'Partido',
label: option.label,
keywords: ['partido', 'sigla', option.value],
}),
)
}
seeds.push(
seedOf({
id: `party:${NO_PARTY_FILTER_VALUE}`,
group: 'Partido',
label: 'Sem partido',
keywords: ['sem', 'partido'],
}),
)
for (const [value, label] of Object.entries(municipalityListCoverageLabels)) {
seeds.push(
seedOf(
@ -339,12 +451,18 @@ export const buildMunicipalityOmniboxSuggestions = ({
regionFilterOptions,
advisorFilterOptions,
slugFilterOptions,
stateDeputyFilterOptions,
leadershipFilterOptions,
partyFilterOptions,
}: {
query: string
showStaffFilters: boolean
regionFilterOptions: readonly MunicipalityFilterOption[]
advisorFilterOptions: readonly MunicipalityFilterOption[]
slugFilterOptions: readonly MunicipalityFilterOption[]
stateDeputyFilterOptions: readonly MunicipalityFilterOption[]
leadershipFilterOptions: readonly MunicipalityFilterOption[]
partyFilterOptions: readonly MunicipalityFilterOption[]
}): CampaignListOmniboxSuggestion[] =>
filterMunicipalityOmniboxSuggestions(
buildMunicipalityOmniboxSuggestionSeeds({
@ -352,6 +470,9 @@ export const buildMunicipalityOmniboxSuggestions = ({
regionFilterOptions,
advisorFilterOptions,
slugFilterOptions,
stateDeputyFilterOptions,
leadershipFilterOptions,
partyFilterOptions,
}),
query,
)

View file

@ -15,6 +15,7 @@ import { relationshipId } from '@/lib/relationship'
import { type VoteEstimateScenario } from '@/lib/voteEstimate'
import type { CampaignUser, Municipality } from '@/payload-types'
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
import {
loadMunicipalityScope,
@ -33,11 +34,13 @@ import {
municipalityPageSize,
parseMunicipalityListParams,
resolveMunicipalityListSort,
type MunicipalityListRelationCatalog,
type MunicipalityListSearchParams,
type MunicipalityListSortDirection,
type MunicipalityListSortKey,
type MunicipalityListState,
} from '@/utilities/municipality/municipalityListUrl'
import { loadMunicipalityListRelationCatalog } from '@/utilities/municipality/municipalityRelationSets'
import { resolveMunicipalityLastSignalAt } from '@/utilities/municipality/municipalitySignal'
import {
computeMunicipalityTerritorialClass,
@ -66,6 +69,12 @@ type MunicipalityListFilterFacets = {
slugs: string[]
regions: string[]
advisorIDs: number[]
/** B176 — stateDeputy ids still reachable under the other filters. */
stateDeputyIDs: number[]
/** B176 — leadership id+name pairs still reachable under the other filters. */
leadershipOptions: MunicipalityLeadershipSummary[]
/** B176 — party names still reachable under the other filters (no sentinel). */
parties: string[]
}
export type MunicipalityListPageBundle = {
@ -84,9 +93,15 @@ const emptyMunicipalityListFilterFacets: MunicipalityListFilterFacets = {
slugs: [],
regions: [],
advisorIDs: [],
stateDeputyIDs: [],
leadershipOptions: [],
parties: [],
}
type MunicipalityFacetRow = Pick<Municipality, 'slug' | 'region' | 'advisors'>
type MunicipalityFacetRow = Pick<
Municipality,
'id' | 'slug' | 'region' | 'advisors' | 'stateDeputies'
>
/**
* E10's class filter is the only one that isn't a Payload constraint (the
@ -115,6 +130,7 @@ const loadMunicipalityListFilterFacets = async (
payload: Payload,
user: CampaignUser,
state: MunicipalityListState,
relationCatalog: MunicipalityListRelationCatalog,
/** The scope read is a PROMISE so the facets can join the page's main `Promise.all` (B16+). */
loadedScope: { where: Where; rows: Promise<MunicipalityFacetRow[]> } | null,
): Promise<MunicipalityListFilterFacets> => {
@ -124,12 +140,19 @@ const loadMunicipalityListFilterFacets = async (
const applyClassFilter = (rows: MunicipalityFacetRow[]): MunicipalityFacetRow[] =>
classMatches ? rows.filter((row) => classMatches(row.slug)) : rows
// Dobradinha links are stored on the rows; a deputy id → party inverse comes
// from the request-scoped catalog, so the Partido facet never re-queries.
const partyOfStateDeputyID = new Map<number, string>()
for (const [party, stateDeputyIDs] of relationCatalog.stateDeputyIDsByParty) {
for (const id of stateDeputyIDs) partyOfStateDeputyID.set(id, party)
}
const rowsByWhere = new Map<string, Promise<MunicipalityFacetRow[]>>()
if (loadedScope) {
rowsByWhere.set(JSON.stringify(loadedScope.where), loadedScope.rows.then(applyClassFilter))
}
const facetRows = (omit: Partial<MunicipalityListState>): Promise<MunicipalityFacetRow[]> => {
const where = buildMunicipalityListWhere({ ...state, ...omit })
const where = buildMunicipalityListWhere({ ...state, ...omit }, relationCatalog)
const key = JSON.stringify(where)
const pending = rowsByWhere.get(key)
if (pending) return pending
@ -141,7 +164,7 @@ const loadMunicipalityListFilterFacets = async (
depth: 0,
limit: 0,
pagination: false,
select: { slug: true, region: true, advisors: true },
select: { id: true, slug: true, region: true, advisors: true, stateDeputies: true },
user,
overrideAccess: false,
})
@ -150,13 +173,29 @@ const loadMunicipalityListFilterFacets = async (
return rows
}
const [slugRows, regionRows, advisorRows] = await Promise.all([
facetRows({ slugs: undefined, priority: undefined }),
facetRows({ regions: undefined }),
facetRows({ advisors: undefined, coverage: undefined }),
])
// Named selections are unioned in: a selection must stay visible to be undone.
const selectedStateDeputyIDs = new Set<number>()
for (const value of state.stateDeputies ?? []) {
if (typeof value === 'number') selectedStateDeputyIDs.add(value)
}
const selectedLeadershipIDs = new Set<number>()
for (const value of state.leaderships ?? []) {
if (typeof value === 'number') selectedLeadershipIDs.add(value)
}
const selectedParties = new Set(
(state.parties ?? []).filter((party) => party !== NO_PARTY_FILTER_VALUE),
)
const [slugRows, regionRows, advisorRows, stateDeputyRows, leadershipRows, partyRows] =
await Promise.all([
facetRows({ slugs: undefined, priority: undefined }),
facetRows({ regions: undefined }),
facetRows({ advisors: undefined, coverage: undefined }),
facetRows({ stateDeputies: undefined }),
facetRows({ leaderships: undefined }),
facetRows({ parties: undefined }),
])
// Selected values are unioned in: a selection must stay visible to be undone.
const availableSlugs = new Set([...slugRows.map((row) => row.slug), ...(state.slugs ?? [])])
const availableRegions = new Set<string>([
...regionRows.map((row) => row.region),
@ -170,12 +209,57 @@ const loadMunicipalityListFilterFacets = async (
}
}
const availableStateDeputyIDs = new Set<number>(selectedStateDeputyIDs)
for (const row of stateDeputyRows) {
for (const stateDeputy of row.stateDeputies ?? []) {
const id = relationshipId(stateDeputy)
if (id !== null) availableStateDeputyIDs.add(id)
}
}
const availableParties = new Set<string>(selectedParties)
for (const row of partyRows) {
for (const stateDeputy of row.stateDeputies ?? []) {
const id = relationshipId(stateDeputy)
if (id === null) continue
const party = partyOfStateDeputyID.get(id)
if (party) availableParties.add(party)
}
}
// Leaderships of the facet's own row set: the reverse read returns the ids
// plus the contact names the filter chips need (honours `canReadLeadership`).
const facetLeadershipRows = await loadMunicipalityLeadershipSummaries(
payload,
user,
leadershipRows.map((row) => row.id),
)
const leadershipNamesById = new Map(facetLeadershipRows.summariesById)
for (const leadershipID of selectedLeadershipIDs) {
const summary = leadershipNamesById.get(leadershipID)
if (!summary) {
// A selected leadership that the reversed read cannot see (deleted or
// scoped out) stays listed so the filter can be undone.
leadershipNamesById.set(leadershipID, {
id: leadershipID,
name: `Liderança #${leadershipID}`,
})
}
}
const availableLeadershipIDs = new Set<number>(selectedLeadershipIDs)
for (const leadershipID of leadershipNamesById.keys()) availableLeadershipIDs.add(leadershipID)
return {
slugs: municipalityCatalog
.filter((entry) => availableSlugs.has(entry.slug))
.map((entry) => entry.slug),
regions: bahiaIdentityTerritories.filter((territory) => availableRegions.has(territory)),
advisorIDs: [...availableAdvisorIDs].sort((left, right) => left - right),
stateDeputyIDs: [...availableStateDeputyIDs].sort((left, right) => left - right),
leadershipOptions: [...availableLeadershipIDs]
.sort((left, right) => left - right)
.map((id) => leadershipNamesById.get(id)!),
parties: [...availableParties].sort((left, right) => left.localeCompare(right, 'pt-BR')),
}
}
@ -294,7 +378,6 @@ export const loadMunicipalityListPageBundle = async (
searchParams: MunicipalityListSearchParams,
): Promise<MunicipalityListPageBundle> => {
const state = parseMunicipalityListParams(searchParams)
const where = buildMunicipalityListWhere(state)
const isStaff = isCampaignStaff(user)
const { sort: sortKey, dir: sortDir } = resolveMunicipalityListSort(state)
@ -309,6 +392,12 @@ export const loadMunicipalityListPageBundle = async (
}
}
// B176 — one request-scoped catalog for every where build (page + facets):
// the reverse (`leadership`) and cross (`party`) filters read it. Lazy per
// active filter, so a plain list visit pays no extra reads.
const relationCatalog = await loadMunicipalityListRelationCatalog(payload, user, state)
const where = buildMunicipalityListWhere(state, relationCatalog)
const ranks = computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR)
const nativeSortField = payloadSortFieldByKey[sortKey]
const classMatches = territorialClassFilterPredicate(state)
@ -342,7 +431,13 @@ export const loadMunicipalityListPageBundle = async (
// Request-scoped shared load (docs + pledge aggregates in one place).
const staffScopePromise = isStaff
? isPagedByPayload
? loadMunicipalityScope(payload, user, where)
? loadMunicipalityScope(payload, user, where, {
// B176 — the paged path seeds the facet memo from these scope rows,
// and the Dobradinha/Partido facets read `stateDeputies` off them.
// Without the widened select those option groups render empty under
// a native sort (the cache key separates the widened call — P3-E).
extraSelect: { stateDeputies: true },
})
: listQuery.then((result) =>
loadMunicipalityScopeFromDocs(payload, result.docs as Municipality[]),
)
@ -363,6 +458,7 @@ export const loadMunicipalityListPageBundle = async (
payload,
user,
state,
relationCatalog,
isStaff
? { where, rows: staffScopePromise.then((scope) => scope?.municipalities ?? []) }
: null,

View file

@ -0,0 +1,97 @@
import 'server-only'
import type { Payload } from 'payload'
import { relationshipId } from '@/lib/relationship'
import type { CampaignUser, Leadership, StateDeputy } from '@/payload-types'
import {
type MunicipalityListRelationCatalog,
type MunicipalityListState,
} from '@/utilities/municipality/municipalityListUrl'
/**
* B176 — the request-scoped relation sets that turn the reverse
* (`leadership.municipalities`) and cross (`stateDeputy.party`) filters into
* municipality-level `where` clauses. State-independent, so a page loads it
* ONCE and every facet variant + the page query + the map derive their own
* subset — no N+1.
*
* Lazy by half with one exception: the leadership half is only read when a
* leadership filter is active (the facets source their names from
* `loadMunicipalityLeadershipSummaries`, not from here), while the party half
* ALWAYS loads — besides the "Sem partido"/named-party `where` clauses it is
* the facet's deputy→party inverse (`partyOfStateDeputyID`), which the Partido
* option group reads even when no party filter is set. A no-filter visit pays
* one small read instead of two.
*
* Scope: every read threads `user` + `overrideAccess: false`, so an advisor's
* catalog only ever reaches the leaderships of the municípios they administer
* (`canReadLeadership`); the dobradinha registry is staff-wide by design.
*/
export const loadMunicipalityListRelationCatalog = async (
payload: Payload,
user: CampaignUser,
state: MunicipalityListState,
): Promise<MunicipalityListRelationCatalog> => {
const needsLeadership = Boolean(state.leaderships?.length)
const [leaderships, stateDeputies] = await Promise.all([
needsLeadership
? payload.find({
collection: 'leadership',
depth: 0,
limit: 0,
pagination: false,
select: { municipalities: true },
where: { municipalities: { exists: true } },
user,
overrideAccess: false,
})
: null,
payload.find({
collection: 'stateDeputy',
depth: 0,
limit: 0,
pagination: false,
select: { party: true },
where: { party: { exists: true } },
user,
overrideAccess: false,
}),
])
const leadershipMunicipalityIDsByLeadership = new Map<number, number[]>()
const allLeadershipMunicipalityIDs = new Set<number>()
if (leaderships) {
for (const doc of leaderships.docs as Leadership[]) {
const leadershipID = relationshipId(doc.id)
if (leadershipID === null) continue
const municipalityIDs = (doc.municipalities ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null)
leadershipMunicipalityIDsByLeadership.set(leadershipID, municipalityIDs)
for (const id of municipalityIDs) allLeadershipMunicipalityIDs.add(id)
}
}
const stateDeputyIDsByParty = new Map<string, number[]>()
const allPartyStateDeputyIDs = new Set<number>()
if (stateDeputies) {
for (const doc of stateDeputies.docs as StateDeputy[]) {
if (!doc.party) continue
const stateDeputyID = relationshipId(doc.id)
if (stateDeputyID === null) continue
const idsByParty = stateDeputyIDsByParty.get(doc.party) ?? []
idsByParty.push(stateDeputyID)
stateDeputyIDsByParty.set(doc.party, idsByParty)
allPartyStateDeputyIDs.add(stateDeputyID)
}
}
return {
leadershipMunicipalityIDsByLeadership,
allLeadershipMunicipalityIDs,
stateDeputyIDsByParty,
allPartyStateDeputyIDs,
}
}

View file

@ -4,10 +4,10 @@ import type { Payload } from 'payload'
import { populatedContactName, relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import type { CampaignUser, StateDeputy } from '@/payload-types'
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
import { loadCampaignUserSummaries } from '@/utilities/campaignRelationOptions'
import {
buildStateDeputyListWhere,
NO_PARTY_FILTER_VALUE,
resolveStateDeputyListPayloadSort,
resolveStateDeputyListSort,
stateDeputyPageSize,

View file

@ -4,10 +4,9 @@
* one filterable column exists, so there is no B16+-style href fast path —
* `stateDeputy` runs in the dozens of rows, not 435.
*/
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
import { NO_PARTY_FILTER_VALUE, truncatedNamesLabel } from '@/utilities/campaignListUrl'
import {
buildStateDeputyListHref,
NO_PARTY_FILTER_VALUE,
parseStateDeputyListParams,
stateDeputyListStateToRawParams,
type StateDeputyListState,

View file

@ -10,10 +10,13 @@ import type { Where } from 'payload'
import {
allParamValues,
buildListHref,
collapseListWhereOrBranches,
createSortToggleHref,
firstValue,
NO_PARTY_FILTER_VALUE,
normalizedText,
resolveListUrl,
splitAbsenceFilterValues,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
@ -23,15 +26,6 @@ export const stateDeputyPageSize = 25
export type StateDeputyListSortKey = 'name' | 'party'
export type StateDeputyListSortDirection = 'asc' | 'desc'
/**
* Sentinel for the "Sem partido" filter row — `party` is a free-text optional
* field (no closed enum to validate a real value against), so this constant
* is the only reserved token; a dobradinha whose actual party name collided
* with it would be indistinguishable, an accepted precedent-level risk (the
* curated field spans a couple dozen Brazilian party acronyms).
*/
export const NO_PARTY_FILTER_VALUE = 'sem_partido'
export type StateDeputyListState = {
page: number
q?: string
@ -128,14 +122,17 @@ export const buildStateDeputyListWhere = (state: StateDeputyListState): Where =>
if (state.q) filters.push({ 'contact.name': { contains: state.q } })
if (state.parties?.length) {
const namedParties = state.parties.filter((party) => party !== NO_PARTY_FILTER_VALUE)
const includesNoParty = state.parties.includes(NO_PARTY_FILTER_VALUE)
const { named: namedParties, hasAbsence } = splitAbsenceFilterValues(
state.parties,
(party) => party === NO_PARTY_FILTER_VALUE,
)
const partyFilters: Where[] = []
if (namedParties.length) partyFilters.push({ party: { in: namedParties } })
if (includesNoParty) partyFilters.push({ party: { exists: false } })
if (hasAbsence) partyFilters.push({ party: { exists: false } })
// Every entry in a non-empty `parties` is named or the sentinel (or both),
// so `partyFilters` always has at least one member here.
filters.push(partyFilters.length > 1 ? { or: partyFilters } : partyFilters[0])
const branch = collapseListWhereOrBranches(partyFilters)
if (branch) filters.push(branch)
}
return filters.length ? { and: filters } : {}

View file

@ -7,6 +7,7 @@ import {
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
import {
buildStateDeputyPartyOptions,
clearStateDeputyListFilters,
@ -14,7 +15,6 @@ import {
type StateDeputyFilterOption,
} from '@/utilities/stateDeputyListFilters'
import {
NO_PARTY_FILTER_VALUE,
resolveStateDeputyListSort,
stateDeputyListSortOptions,
type StateDeputyListSortDirection,

View file

@ -131,7 +131,9 @@ test.describe('Municípios — jornadas por papel', () => {
// B145: assessoria left the detail hero; it still appears on the dossiê tab.
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=dossie`)
await expect(page.getByText(`Assessoria: ${advisor.name}`)).toBeVisible()
// `.nth(0)`: same transient hydration duplicate the "Otimista: 5.000"
// assertion pins — the server-composed slot briefly renders twice.
await expect(page.getByText(`Assessoria: ${advisor.name}`).nth(0)).toBeVisible()
})
test('coordinator assigns an advisor from the list combobox with auto-save (B27)', async ({
@ -357,6 +359,159 @@ test.describe('Municípios — jornadas por papel', () => {
).toBeVisible()
})
test('B176 — filters the list by Dobradinha, Liderança and Partido from the omnibox', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador Filtros B176'),
})
const municipalityA = await fixtures.claimMunicipality()
const municipalityB = await fixtures.claimMunicipality()
// A dobradinha linked to municipality A only, with a distinctive party so
// the Partido facet cannot collide with other parallel specs.
const deputyName = fixtures.value('Deputada B176')
const deputyContact = await fixtures.payload.create({
collection: 'contact',
data: { name: deputyName, state: 'BA' },
depth: 0,
})
const deputy = await fixtures.payload.create({
collection: 'stateDeputy',
data: { contact: deputyContact.id, party: 'BR176', slug: fixtures.value('deputado') },
depth: 0,
draft: false,
})
await fixtures.payload.update({
collection: 'municipality',
id: municipalityA.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
})
fixtures.touchMunicipality(municipalityA.id)
// A leadership acting on municipality B only.
const leadership = await fixtures.createStaffLeadership({
namePrefix: 'Liderança B176',
municipalities: [municipalityB],
})
await campaign.login(page, coordinator.email!, coordinator.password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
const omnibox = page.getByRole('combobox', { name: 'Filtrar municípios' })
const listbox = page.getByRole('listbox', { name: 'Filtrar municípios' })
const tableRows = municipalityContainer(page).locator('tbody tr')
// The omnibox chips live in the search form ABOVE the list container.
const removeChip = (label: string) => page.getByRole('button', { name: `Remover ${label}` })
// Dobradinha by contact name → chip + only municipality A remains.
await omnibox.fill(deputyName)
await listbox.getByRole('option', { name: new RegExp(`^${deputyName} \\(BR176\\)$`) }).click()
await expect(removeChip(`Dobradinha: ${deputyName} (BR176)`)).toBeVisible()
await expect(tableRows).toHaveCount(1)
await expect(page.getByRole('link', { name: municipalityA.name, exact: true })).toBeVisible()
// Removing the chip restores the unfiltered 25-row page 1.
await removeChip(`Dobradinha: ${deputyName} (BR176)`).click()
await expect(removeChip(`Dobradinha: ${deputyName} (BR176)`)).toHaveCount(0)
// Partido by acronym → the same single municipality (its only dobradinha).
await omnibox.fill('BR176')
await listbox.getByRole('option', { name: 'BR176', exact: true }).click()
await expect(removeChip('Partido: BR176')).toBeVisible()
await expect(tableRows).toHaveCount(1)
await expect(page.getByRole('link', { name: municipalityA.name, exact: true })).toBeVisible()
await removeChip('Partido: BR176').click()
await expect(removeChip('Partido: BR176')).toHaveCount(0)
// Liderança by contact name → municipality B only.
await omnibox.fill(leadership.contactName)
await listbox.getByRole('option', { name: new RegExp(`^${leadership.contactName}$`) }).click()
await expect(removeChip(`Liderança: ${leadership.contactName}`)).toBeVisible()
await expect(tableRows).toHaveCount(1)
await expect(page.getByRole('link', { name: municipalityB.name, exact: true })).toBeVisible()
})
test('B176 — advisor edits Dobradinhas inline within an administered municipality', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessor B176'),
})
const municipality = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
id: municipality.id,
data: { advisors: [advisor.id] },
depth: 0,
})
fixtures.touchMunicipality(municipality.id)
// A dobradinha linked to NO municipality — the full staff catalog must
// still offer it to the advisor in the cell (gate 2026-08-09).
const deputyName = fixtures.value('Deputado Disponível B176')
const contact = await fixtures.payload.create({
collection: 'contact',
data: { name: deputyName, state: 'BA', city: null },
depth: 0,
})
await fixtures.payload.create({
collection: 'stateDeputy',
data: { contact: contact.id, party: 'PSD', slug: fixtures.value('deputado') },
depth: 0,
draft: false,
})
await campaign.login(page, advisor.email!, advisor.password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
await visibleMunicipalityButton(page, `Editar dobradinhas em ${municipality.name}`).click()
const popover = page.locator('[data-slot="popover-content"]')
await expect(popover).toBeVisible()
const search = popover.getByRole('combobox', { name: 'Buscar dobradinha' })
const chip = popover.getByRole('button', { name: `Remover ${deputyName} (PSD)` })
await expect(async () => {
await search.fill(deputyName)
// Register the write-waiter before the click: asserting the optimistic
// chip alone proves nothing about the database, and a reload racing the
// POST would capture the pre-write state (the B32+ auto-save race).
const saved = expectPostResponse(page, '/campanha/municipios')
await popover.getByRole('option', { name: new RegExp(`^${deputyName} \\(PSD\\)$`) }).click()
await expect(chip).toBeVisible({ timeout: 5_000 })
await saved
}).toPass({ timeout: 25_000 })
await page.keyboard.press('Escape')
// Persistence across reload: the server holds the assignment.
await page.reload()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
await expect(async () => {
await visibleMunicipalityButton(page, `Editar dobradinhas em ${municipality.name}`).click()
await expect(
page.locator('[data-slot="popover-content"]').getByRole('button', {
name: `Remover ${deputyName} (PSD)`,
}),
).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 25_000 })
})
test('coordinator sets trend status and justification with auto-save (B24)', async ({
campaign,
page,

View file

@ -204,7 +204,7 @@ test.describe('B158 — colunas responsivas por largura do conteúdo', () => {
await expectNoHorizontalOverflow(container)
})
test('keeps Dobradinha restricted even when an advisor has enough room', async ({
test('releases the Dobradinhas column to the advisor at full width (B176)', async ({
campaign,
page,
}) => {
@ -213,7 +213,9 @@ test.describe('B158 — colunas responsivas por largura do conteúdo', () => {
await setContainerWidth(container, 84 * REM_IN_PIXELS + 1)
await expect(container.getByRole('columnheader', { name: /Assessores/ })).toBeVisible()
await expect(container.getByRole('columnheader', { name: 'Dobradinha' })).toHaveCount(0)
// B176 (2026-08-09): the Dobradinhas column is staff-wide — the advisor
// sees it once the container has room, same as the coordinator/candidate.
await expect(container.getByRole('columnheader', { name: 'Dobradinha' })).toBeVisible()
await expectNoHorizontalOverflow(container)
})

View file

@ -74,7 +74,10 @@ test('enrolls a passkey, signs in with it, then revokes it', async ({
await expectCampaignBiometricsReady(page)
await page.goto('/campanha/perfil')
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeVisible()
// `.nth(0)`: during hydration the server-composed slot can briefly coexist
// with its re-rendered copy (same transient duplicate the municipios spec
// pins for "Otimista: 5.000") — the copy settles to one element.
await expect(page.getByText('Nenhum aparelho cadastrado ainda.').nth(0)).toBeVisible()
const deviceLabel = page.getByLabel('Nome deste aparelho')
await expect(deviceLabel).toBeVisible()

View file

@ -425,6 +425,7 @@ class CampaignE2EOwnership {
levelNote: null,
strengths: [],
risks: [],
stateDeputies: [],
dobradinhaNotes: null,
nextSteps: null,
lastUpdateAt: null,

View file

@ -12,10 +12,10 @@ import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { loadAdvisorListPageData } from '@/utilities/advisorData'
import { loadDemandListPageData } from '@/utilities/campaignDemandData'
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
import { loadLeadershipListPageData } from '@/utilities/leadership/leadershipData'
import { loadOrganizationListPageData } from '@/utilities/organizationData'
import { loadStateDeputyDetail, loadStateDeputyListPageData } from '@/utilities/stateDeputyData'
import { NO_PARTY_FILTER_VALUE } from '@/utilities/stateDeputyListUrl'
import { installCampaignFixtures } from '../helpers/campaignFixtures'

View file

@ -0,0 +1,303 @@
// @vitest-environment node
/**
* B176 — the municipality-list relation filters (Dobradinha, Liderança,
* Partido) and their edge semantics. Pins the exact drizzle behaviour this
* feature depends on: `in: []` matches nothing, `not_in: []` matches
* everything, `exists: false` on a hasMany matches the absent array, and the
* request-scoped catalog turns the reverse/cross filters into municipality
* `where` clauses that scope against the actor.
*/
import type { Payload, Where } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
import {
buildMunicipalityListWhere,
NO_LEADERSHIP_FILTER_VALUE,
NO_STATE_DEPUTY_FILTER_VALUE,
parseMunicipalityListParams,
type MunicipalityListSearchParams,
type MunicipalityListState,
} from '@/utilities/municipality/municipalityListUrl'
import { loadMunicipalityListRelationCatalog } from '@/utilities/municipality/municipalityRelationSets'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
describe('municipality list relation filters (B176)', () => {
it('pins the drizzle absence/empty-array semantics on the municipality collection', async () => {
const fixtures = campaignFixtures()
const [a, b, c] = await Promise.all([
fixtures.getMunicipality(),
fixtures.getMunicipality(),
fixtures.getMunicipality(),
])
// `idsFor` returns ascending ids, so the expected scope must be sorted too.
const scope = [a.id, b.id, c.id].sort((left, right) => left - right)
const idsFor = async (extra: Where): Promise<number[]> => {
const result = await payload.find({
collection: 'municipality',
where: {
and: [{ id: { in: scope } }, extra],
},
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
return result.docs.map((doc) => doc.id).sort((left, right) => left - right)
}
// `in: []` matches nothing; `not_in: []` matches everything in scope.
expect(await idsFor({ id: { in: [] } })).toEqual([])
expect(await idsFor({ id: { not_in: [] } })).toEqual(scope)
// `exists: false`/`in` on the hasMany `stateDeputies` array feeds the
// whole dobradinha dimension.
expect(await idsFor({ stateDeputies: { exists: false } })).toEqual(scope)
expect(await idsFor({ stateDeputies: { exists: true } })).toEqual([])
})
it('filters by dobradinha and "Sem dobradinha" (direct relationship)', async () => {
const fixtures = campaignFixtures()
const a = await fixtures.getMunicipality()
const b = await fixtures.getMunicipality()
const c = await fixtures.getMunicipality()
const scope = [a.id, b.id, c.id]
const deputy = await fixtures.createStateDeputy({ name: fixtures.value('Deputado Filtro') })
await payload.update({
collection: 'municipality',
id: a.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
overrideAccess: true,
})
await payload.update({
collection: 'municipality',
id: b.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
overrideAccess: true,
})
fixtures.touchMunicipality(a.id)
fixtures.touchMunicipality(b.id)
const state = (raw: MunicipalityListSearchParams): MunicipalityListState =>
parseMunicipalityListParams(raw)
const idsFor = async (extra: Where): Promise<number[]> => {
const result = await payload.find({
collection: 'municipality',
where: { and: [{ id: { in: scope } }, extra] },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
return result.docs.map((doc) => doc.id).sort((left, right) => left - right)
}
expect(
await idsFor(buildMunicipalityListWhere(state({ stateDeputy: [String(deputy.id)] }))),
).toEqual([a.id, b.id])
expect(
await idsFor(
buildMunicipalityListWhere(state({ stateDeputy: [NO_STATE_DEPUTY_FILTER_VALUE] })),
),
).toEqual([c.id])
// Named OR sentinel in one query: the dobradinha OR no dobradinha.
expect(
await idsFor(
buildMunicipalityListWhere(
state({ stateDeputy: [String(deputy.id), NO_STATE_DEPUTY_FILTER_VALUE] }),
),
),
).toEqual([a.id, b.id, c.id])
})
it('filters by leadership and "Sem liderança" through the reverse catalog', async () => {
const fixtures = campaignFixtures()
const a = await fixtures.getMunicipality()
const b = await fixtures.getMunicipality()
const c = await fixtures.getMunicipality()
const scope = [a.id, b.id, c.id]
const leadership = await fixtures.createLeadership({
contact: (await fixtures.createContact()).id,
municipalities: [b.id, c.id],
})
const coordinator = await fixtures.createCampaignUser('coordinator')
const catalog = await loadMunicipalityListRelationCatalog(
payload,
coordinator,
parseMunicipalityListParams({ leadership: [String(leadership.id)] }),
)
expect(catalog.leadershipMunicipalityIDsByLeadership.get(leadership.id)).toEqual(
expect.arrayContaining([b.id, c.id]),
)
expect([...catalog.allLeadershipMunicipalityIDs]).toEqual(expect.arrayContaining([b.id, c.id]))
const state = (raw: MunicipalityListSearchParams): MunicipalityListState =>
parseMunicipalityListParams(raw)
const idsFor = async (extra: Where): Promise<number[]> => {
const result = await payload.find({
collection: 'municipality',
where: { and: [{ id: { in: scope } }, extra] },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
return result.docs.map((doc) => doc.id).sort((left, right) => left - right)
}
expect(
await idsFor(
buildMunicipalityListWhere(state({ leadership: [String(leadership.id)] }), catalog),
),
).toEqual([b.id, c.id])
// "Sem liderança" = not linked to ANY scoped leadership → only A.
expect(
await idsFor(
buildMunicipalityListWhere(state({ leadership: [NO_LEADERSHIP_FILTER_VALUE] }), catalog),
),
).toEqual([a.id])
// Fails closed without the catalog.
expect(() =>
buildMunicipalityListWhere(state({ leadership: [NO_LEADERSHIP_FILTER_VALUE] })),
).toThrow(/missing relation catalog/)
})
it('filters by party and "Sem partido" through the party→deputy catalog', async () => {
const fixtures = campaignFixtures()
const a = await fixtures.getMunicipality()
const b = await fixtures.getMunicipality()
const c = await fixtures.getMunicipality()
const scope = [a.id, b.id, c.id]
const pt = await fixtures.createStateDeputy({
name: fixtures.value('Deputado PT'),
party: 'PT',
})
const pcredo = await fixtures.createStateDeputy({
name: fixtures.value('Deputado PCdoB'),
party: 'PCdoB',
})
// A "sem partido" deputy linked to B only.
const noParty = await fixtures.createStateDeputy({ name: fixtures.value('Deputado Sem Sigla') })
await payload.update({
collection: 'municipality',
id: a.id,
data: { stateDeputies: [pt.id] },
depth: 0,
overrideAccess: true,
})
await payload.update({
collection: 'municipality',
id: b.id,
data: { stateDeputies: [pcredo.id, noParty.id] },
depth: 0,
overrideAccess: true,
})
fixtures.touchMunicipality(a.id)
fixtures.touchMunicipality(b.id)
const coordinator = await fixtures.createCampaignUser('coordinator')
const catalog = await loadMunicipalityListRelationCatalog(
payload,
coordinator,
parseMunicipalityListParams({ party: ['PT'] }),
)
expect([...catalog.allPartyStateDeputyIDs]).toEqual(expect.arrayContaining([pt.id, pcredo.id]))
const state = (raw: MunicipalityListSearchParams): MunicipalityListState =>
parseMunicipalityListParams(raw)
const idsFor = async (extra: Where): Promise<number[]> => {
const result = await payload.find({
collection: 'municipality',
where: { and: [{ id: { in: scope } }, extra] },
depth: 0,
limit: 0,
pagination: false,
overrideAccess: true,
})
return result.docs.map((doc) => doc.id).sort((left, right) => left - right)
}
// PT alone → A; PCdoB + "Sem partido" (OR) → B and C (C has no dobradinha).
expect(await idsFor(buildMunicipalityListWhere(state({ party: ['PT'] }), catalog))).toEqual([
a.id,
])
expect(
await idsFor(
buildMunicipalityListWhere(state({ party: ['PCdoB', NO_PARTY_FILTER_VALUE] }), catalog),
),
).toEqual([b.id, c.id])
// A party with no deputy matches nothing.
expect(await idsFor(buildMunicipalityListWhere(state({ party: ['PSD'] }), catalog))).toEqual([])
expect(() => buildMunicipalityListWhere(state({ party: ['PT'] }))).toThrow(
/missing relation catalog/,
)
})
it('scopes the leadership facet catalog to the advisor portfolio', async () => {
const fixtures = campaignFixtures()
const inScope = await fixtures.getMunicipality()
const outOfScope = await fixtures.getMunicipality()
await fixtures.createLeadership({
contact: (await fixtures.createContact()).id,
municipalities: [inScope.id],
})
await fixtures.createLeadership({
contact: (await fixtures.createContact()).id,
municipalities: [outOfScope.id],
})
const advisor = await fixtures.createCampaignUser('advisor')
await payload.update({
collection: 'municipality',
id: inScope.id,
data: { advisors: [advisor.id] },
depth: 0,
overrideAccess: true,
})
const catalog = await loadMunicipalityListRelationCatalog(
payload,
advisor,
parseMunicipalityListParams({ leadership: ['1'] }),
)
// Only the administered municipality's leaderships reach the catalog.
expect([...catalog.allLeadershipMunicipalityIDs]).not.toContain(outOfScope.id)
expect(catalog.allLeadershipMunicipalityIDs.has(inScope.id)).toBe(true)
})
})

View file

@ -310,12 +310,14 @@ describe('campaign visual foundation', () => {
// B155: the Lideranças column chips the linked leaderships by name.
expect(html).toContain('Lideranças')
expect(html).toContain('Maria de Jesus')
// B22: every one of the 10 visible-by-default staff columns carries a header explanation —
// pinned inside `<thead>` itself, since `cellTooltip` (e.g. "Classe") and
// the mobile card's own class tooltip (B42) add triggers of their own.
// B22 + B176: every one of the 11 visible-by-default staff columns carries
// a header explanation — pinned inside `<thead>` itself, since `cellTooltip`
// (e.g. "Classe") and the mobile card's own class tooltip (B42) add
// triggers of their own. 11 = the 10 staff baseline + Dobradinha, which is
// now staff-wide (B176).
const [, tbodyHtml = ''] = html.split('<tbody')
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10)
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
// B158: the table is container-driven and clips instead of delegating to a
// horizontal scroller. Município remains pinned and owns the territory subline.
@ -393,7 +395,7 @@ describe('campaign visual foundation', () => {
expect(html).toContain('>—<')
})
it('keeps the Dobradinhas column out of the advisor and leader views', () => {
it('shows the Dobradinhas column to the advisor and hides it from the leader', () => {
const municipalities: MunicipalityListViewModel[] = [
stub<MunicipalityListViewModel>({
id: 1,
@ -423,17 +425,19 @@ describe('campaign visual foundation', () => {
}),
]
// B176 (2026-08-09): the Dobradinhas column is staff-wide — an advisor
// sees it (the write is scoped to their portfolio server-side), while
// the leader lockdown keeps it off the leader view.
const advisorHtml = renderWithAppRouter(
createElement(MunicipalityList, {
municipalities,
advisorNamesById: new Map(),
...municipalityListDefaultProps,
isStaffView: true,
// Advisor: staff view, but NOT unrestricted — the column must not render.
isCampaignUnrestricted: false,
}),
)
expect(advisorHtml).not.toContain('Dobradinhas')
expect(advisorHtml).toContain('Dobradinhas')
const leaderHtml = renderWithAppRouter(
createElement(MunicipalityList, {
@ -790,7 +794,6 @@ describe('campaign visual foundation', () => {
it('keeps removed and internal columns out of the municipality picker', () => {
const columns = municipalityListPickerColumns({
isStaffView: true,
isCampaignUnrestricted: true,
})
expect(columns.map((column) => column.id)).toEqual([
@ -840,7 +843,9 @@ describe('campaign visual foundation', () => {
// The staff `<thead>` already carries 10 header-explanation tooltips
// (B22), regardless of row data. This baseline isolates the advisors
// CELL tooltip, the one this test actually exercises.
const HEADER_TOOLTIP_COUNT = 10
// 11 header explanations: the 10 staff columns + the now staff-wide
// Dobradinhas (B176).
const HEADER_TOOLTIP_COUNT = 11
const baseMunicipality = {
id: 1,
name: 'Seabra',

View file

@ -479,3 +479,169 @@ describe('buildMunicipalityFilterOptionHref (B16+ fast path)', () => {
)
})
})
describe('municipality list relation filters (B176)', () => {
it('canonicalizes stateDeputy/leadership ids ascending with the sentinel last', () => {
const state = parseMunicipalityListParams({
stateDeputy: ['5', 'sem_dobradinha', '3', '5', 'abc'],
leadership: ['sem_lideranca', '2', '8'],
})
expect(state.stateDeputies).toEqual([3, 5, 'sem_dobradinha'])
expect(state.leaderships).toEqual([2, 8, 'sem_lideranca'])
expect(buildMunicipalityListHref(state, 1)).toBe(
'/campanha/municipios?stateDeputy=3&stateDeputy=5&stateDeputy=sem_dobradinha&leadership=2&leadership=8&leadership=sem_lideranca',
)
})
it('parses parties by insertion order, deduped, with the sentinel last', () => {
const state = parseMunicipalityListParams({
party: ['PSD', 'PT', 'PSD', 'sem_partido'],
})
expect(state.parties).toEqual(['PSD', 'PT', 'sem_partido'])
expect(buildMunicipalityListHref(state, 1)).toBe(
'/campanha/municipios?party=PSD&party=PT&party=sem_partido',
)
// The sentinel canonicalizes LAST regardless of where it appeared, so one
// value set maps to one URL (same contract as the relationship dims).
const sentinelFirst = parseMunicipalityListParams({ party: ['sem_partido', 'PT', 'PT'] })
expect(sentinelFirst.parties).toEqual(['PT', 'sem_partido'])
expect(buildMunicipalityListHref(sentinelFirst, 1)).toBe(
'/campanha/municipios?party=PT&party=sem_partido',
)
})
it('serializes the three dimensions together with the rest of the state', () => {
const state = parseMunicipalityListParams({
region: 'Irecê',
stateDeputy: '4',
leadership: ['sem_lideranca', '9'],
party: 'PCdoB',
})
const href = buildMunicipalityListHref(state, 1)
expect(href).toContain('stateDeputy=4')
expect(href).toContain('leadership=9&leadership=sem_lideranca')
expect(href).toContain('party=PCdoB')
// URL → raw plain-record params → parse, mirroring what the page's
// `searchParams` object carries into `parseMunicipalityListParams`.
const query = new URLSearchParams(href.split('?')[1])
const raw: Record<string, string | string[] | undefined> = {}
for (const key of query.keys()) {
const values = query.getAll(key)
raw[key] = values.length === 1 ? values[0] : values
}
expect(parseMunicipalityListParams(raw)).toEqual(state)
})
it('builds the direct dobradinha where (in / exists), no catalog needed', () => {
expect(buildMunicipalityListWhere(parseMunicipalityListParams({ stateDeputy: '4' }))).toEqual({
and: [{ stateDeputies: { in: [4] } }],
})
expect(
buildMunicipalityListWhere(parseMunicipalityListParams({ stateDeputy: 'sem_dobradinha' })),
).toEqual({ and: [{ stateDeputies: { exists: false } }] })
expect(
buildMunicipalityListWhere(
parseMunicipalityListParams({ stateDeputy: ['4', 'sem_dobradinha'] }),
),
).toEqual({
and: [{ or: [{ stateDeputies: { in: [4] } }, { stateDeputies: { exists: false } }] }],
})
})
const catalog = {
leadershipMunicipalityIDsByLeadership: new Map([
[1, [10, 11]],
[2, [12]],
]),
allLeadershipMunicipalityIDs: new Set([10, 11, 12]),
stateDeputyIDsByParty: new Map([
['PT', [4, 5]],
['PSD', [6]],
]),
allPartyStateDeputyIDs: new Set([4, 5, 6]),
}
it('builds the reverse leadership where from the catalog (in / not_in)', () => {
expect(
buildMunicipalityListWhere(parseMunicipalityListParams({ leadership: ['1', '2'] }), catalog),
).toEqual({ and: [{ id: { in: [10, 11, 12] } }] })
expect(
buildMunicipalityListWhere(
parseMunicipalityListParams({ leadership: 'sem_lideranca' }),
catalog,
),
).toEqual({ and: [{ id: { not_in: [10, 11, 12] } }] })
})
it('builds the cross party where from the catalog (in / not_in)', () => {
expect(
buildMunicipalityListWhere(parseMunicipalityListParams({ party: ['PT'] }), catalog),
).toEqual({ and: [{ stateDeputies: { in: [4, 5] } }] })
expect(
buildMunicipalityListWhere(parseMunicipalityListParams({ party: 'sem_partido' }), catalog),
).toEqual({ and: [{ stateDeputies: { not_in: [4, 5, 6] } }] })
expect(
buildMunicipalityListWhere(
parseMunicipalityListParams({ party: ['PT', 'sem_partido'] }),
catalog,
),
).toEqual({
and: [{ or: [{ stateDeputies: { in: [4, 5] } }, { stateDeputies: { not_in: [4, 5, 6] } }] }],
})
})
it('fails closed: leadership/party filter without the catalog throws', () => {
expect(() =>
buildMunicipalityListWhere(parseMunicipalityListParams({ leadership: '1' })),
).toThrow(/missing relation catalog/)
expect(() => buildMunicipalityListWhere(parseMunicipalityListParams({ party: 'PT' }))).toThrow(
/missing relation catalog/,
)
// Dobradinha (direct) never needs the catalog.
expect(() =>
buildMunicipalityListWhere(parseMunicipalityListParams({ stateDeputy: '4' })),
).not.toThrow()
})
it('summarizes the three dimensions in the active-filters summary', () => {
expect(
formatMunicipalityActiveFiltersSummary(
parseMunicipalityListParams({ stateDeputy: ['1', '2'] }),
),
).toBe('2 dobradinhas')
expect(
formatMunicipalityActiveFiltersSummary(
parseMunicipalityListParams({ stateDeputy: ['1', 'sem_dobradinha'] }),
),
).toBe('1 dobradinha + sem dobradinha')
expect(
formatMunicipalityActiveFiltersSummary(
parseMunicipalityListParams({ leadership: 'sem_lideranca' }),
),
).toBe('Sem liderança')
expect(
formatMunicipalityActiveFiltersSummary(
parseMunicipalityListParams({ party: ['PT', 'PSD', 'sem_partido'] }),
),
).toBe('Partido PT, PSD + sem partido')
})
it('keeps the B176 toggles on the canonical fast-path href', () => {
const state = parseMunicipalityListParams({ party: 'PT' })
expect(buildMunicipalityFilterOptionHref(state, 'stateDeputy', '5')).toBe(
buildMunicipalityFilterHref(toggleMunicipalityMultiFilterValue(state, 'stateDeputy', '5')),
)
expect(buildMunicipalityFilterOptionHref(state, 'leadership', 'sem_lideranca')).toBe(
buildMunicipalityFilterHref(
toggleMunicipalityMultiFilterValue(state, 'leadership', 'sem_lideranca'),
),
)
expect(buildMunicipalityFilterOptionHref(state, 'party', 'PSD')).toBe(
buildMunicipalityFilterHref(toggleMunicipalityMultiFilterValue(state, 'party', 'PSD')),
)
})
})

View file

@ -26,6 +26,8 @@ describe('municipality omnibox (B127)', () => {
scenario: 'optimistic',
showStaffFilters: true,
advisorLabelsById: new Map(),
stateDeputyLabelsById: new Map(),
leadershipLabelsById: new Map(),
})
expect(chips.map((chip) => chip.id)).toEqual([
'q',
@ -43,6 +45,8 @@ describe('municipality omnibox (B127)', () => {
scenario: DEFAULT_VOTE_ESTIMATE_SCENARIO,
showStaffFilters: true,
advisorLabelsById: new Map(),
stateDeputyLabelsById: new Map(),
leadershipLabelsById: new Map(),
})
expect(chips).toEqual([])
})
@ -54,6 +58,9 @@ describe('municipality omnibox (B127)', () => {
regionFilterOptions: [],
advisorFilterOptions: [],
slugFilterOptions: [{ value: 'salvador', label: 'Salvador' }],
stateDeputyFilterOptions: [],
leadershipFilterOptions: [],
partyFilterOptions: [],
})
expect(suggestions.some((entry) => entry.id === 'q:pri')).toBe(true)
expect(suggestions.some((entry) => entry.id === 'priority:alta')).toBe(true)
@ -66,10 +73,93 @@ describe('municipality omnibox (B127)', () => {
regionFilterOptions: [],
advisorFilterOptions: [],
slugFilterOptions: [],
stateDeputyFilterOptions: [],
leadershipFilterOptions: [],
partyFilterOptions: [],
})
expect(suggestions.some((entry) => entry.group === 'Ordenação')).toBe(true)
})
it('suggests Dobradinha, Liderança and Partido options (B176)', () => {
const suggestions = buildMunicipalityOmniboxSuggestions({
query: 'mar',
showStaffFilters: true,
regionFilterOptions: [],
advisorFilterOptions: [],
slugFilterOptions: [],
stateDeputyFilterOptions: [{ value: '12', label: 'Maria Souza (PSD)' }],
leadershipFilterOptions: [{ value: '7', label: 'Maria de Fátima' }],
partyFilterOptions: [{ value: 'PSD', label: 'PSD' }],
})
expect(suggestions.some((entry) => entry.id === 'stateDeputy:12')).toBe(true)
expect(suggestions.some((entry) => entry.id === 'leadership:7')).toBe(true)
// Typing a party acronym matches the label itself.
const byParty = buildMunicipalityOmniboxSuggestions({
query: 'psd',
showStaffFilters: true,
regionFilterOptions: [],
advisorFilterOptions: [],
slugFilterOptions: [],
stateDeputyFilterOptions: [],
leadershipFilterOptions: [],
partyFilterOptions: [{ value: 'PSD', label: 'PSD' }],
})
expect(byParty.some((entry) => entry.id === 'party:PSD')).toBe(true)
})
it('reveals the "Sem …" sentinel options while typing "sem"', () => {
const suggestions = buildMunicipalityOmniboxSuggestions({
query: 'sem',
showStaffFilters: true,
regionFilterOptions: [],
advisorFilterOptions: [],
slugFilterOptions: [],
stateDeputyFilterOptions: [],
leadershipFilterOptions: [],
partyFilterOptions: [],
})
expect(suggestions.map((entry) => entry.id)).toEqual(
expect.arrayContaining([
'stateDeputy:sem_dobradinha',
'leadership:sem_lideranca',
'party:sem_partido',
]),
)
})
it('applies the B176 relation filters inclusively and removes single chips', () => {
const applied = applyMunicipalityOmniboxSuggestion({
state: base,
suggestionId: 'stateDeputy:12',
})
expect(applied.kind).toBe('url')
if (applied.kind !== 'url') return
expect(applied.state.stateDeputies).toEqual([12])
const withSentinel = applyMunicipalityOmniboxSuggestion({
state: applied.state,
suggestionId: 'leadership:sem_lideranca',
})
expect(withSentinel.kind).toBe('url')
if (withSentinel.kind === 'url') {
expect(withSentinel.state.leaderships).toEqual(['sem_lideranca'])
}
const withParty = applyMunicipalityOmniboxSuggestion({
state: base,
suggestionId: 'party:PSD',
})
expect(withParty.kind).toBe('url')
if (withParty.kind === 'url') expect(withParty.state.parties).toEqual(['PSD'])
const removed = removeMunicipalityOmniboxChip({
state: parseMunicipalityListParams({ stateDeputy: ['12', '5'] }),
chipId: 'stateDeputy:5',
})
expect(removed.kind).toBe('url')
if (removed.kind === 'url') expect(removed.state.stateDeputies).toEqual([12])
})
it('applies text search and toggles inclusive filters', () => {
const search = applyMunicipalityOmniboxSuggestion({
state: base,

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
import {
clearStateDeputyListFilters,
clearStateDeputyPartyFilter,
@ -9,7 +10,6 @@ import {
import {
buildStateDeputyListHref,
buildStateDeputySortHref,
NO_PARTY_FILTER_VALUE,
parseStateDeputyListParams,
parseStateDeputySortValue,
resolveStateDeputyListPayloadSort,