diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index f07b8b48..eeb2675f 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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-` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-` 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`/`teqo_wt_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). diff --git a/docs/plans/filtros-municipios-dobradinha-lideranca-impl.md b/docs/plans/filtros-municipios-dobradinha-lideranca-impl.md new file mode 100644 index 00000000..430d04d5 --- /dev/null +++ b/docs/plans/filtros-municipios-dobradinha-lideranca-impl.md @@ -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
novos params: stateDeputy, leadership, party + sentinelas] --> B[municipalityListFilters
multi-params + summary + fast-path toggles] + B --> C[municipalityOmnibox
grupos Dobradinha/Liderança/Partido + Sem…] + D[loadMunicipalityListRelationSets
server-only: conjuntos pré-calculados] --> E[buildMunicipalityListWhere
+ sets no where final] + D --> F[loadMunicipalityListFilterFacets
+ stateDeputyIDs/leadershipIDs/parties] + F --> G[page + MunicipalityFilters
opções rotuladas + maps id→nome] + H[page: libera coluna ao staff
stateDeputyOptions p/ todo staff] --> I[MunicipalityList + MobileCards
coluna sem gate isCampaignUnrestricted] + E --> J[municipalityMapData
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:`, `leadership:`, `party:` 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`. diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index deafacbf..6b48536c 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -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 = ( } diff --git a/src/components/campaign/municipality/MunicipalityFilters.tsx b/src/components/campaign/municipality/MunicipalityFilters.tsx index d9745b99..a9ce46d1 100644 --- a/src/components/campaign/municipality/MunicipalityFilters.tsx +++ b/src/components/campaign/municipality/MunicipalityFilters.tsx @@ -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 => { + const map = new Map() + 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() - 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( diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index dd778c55..063fb82b 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -484,37 +484,36 @@ const municipalityListColumns = ({ /> ), }, - ...(isCampaignUnrestricted - ? ([ - { - id: 'stateDeputies', - label: municipalityColumnLabels.stateDeputies, - head: ( - - {municipalityColumnLabels.stateDeputies} - - ), - cellClassName: cn( - responsiveColumnClassName.stateDeputies, - 'max-w-56 whitespace-normal', - ), - cell: (municipality) => ( - - ), - }, - ] satisfies Array) - : []), + { + // 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: ( + + {municipalityColumnLabels.stateDeputies} + + ), + cellClassName: cn( + responsiveColumnClassName.stateDeputies, + 'max-w-56 whitespace-normal', + ), + cell: (municipality) => ( + + ), + }, { id: 'goalCoverage', @@ -592,11 +591,7 @@ const municipalityListColumns = ({ export const municipalityListPickerColumns = ({ isStaffView, - isCampaignUnrestricted, -}: Pick< - MunicipalityListProps, - 'isStaffView' | 'isCampaignUnrestricted' ->): CampaignColumnPickerColumn[] => { +}: Pick): 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 }, ] diff --git a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx index aaba1daf..8df432a3 100644 --- a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx +++ b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx @@ -234,7 +234,9 @@ export const MunicipalityListMobileCards = ({ /> - {isCampaignUnrestricted ? ( + {/* B176 — staff-wide since 2026-08-09; the write stays scoped to + the actor's administered municípios (B37/B157). */} + {isStaffView ? (
Dobradinhas
diff --git a/src/utilities/campaignListUrl.ts b/src/utilities/campaignListUrl.ts index ec9fbb27..fa5f8ce4 100644 --- a/src/utilities/campaignListUrl.ts +++ b/src/utilities/campaignListUrl.ts @@ -1,5 +1,41 @@ +import type { Where } from 'payload' + export type RawSearchParams = Record +/** + * 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 = ( + 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 diff --git a/src/utilities/municipality/municipalityListFilters.ts b/src/utilities/municipality/municipalityListFilters.ts index 13d34f68..ac35448c 100644 --- a/src/utilities/municipality/municipalityListFilters.ts +++ b/src/utilities/municipality/municipalityListFilters.ts @@ -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 => { diff --git a/src/utilities/municipality/municipalityListUrl.ts b/src/utilities/municipality/municipalityListUrl.ts index 9dfa4b78..3ab2e232 100644 --- a/src/utilities/municipality/municipalityListUrl.ts +++ b/src/utilities/municipality/municipalityListUrl.ts @@ -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 + /** Municipality ids linked to ANY scoped leadership — for "Sem liderança". */ + allLeadershipMunicipalityIDs: ReadonlySet + /** Party name → stateDeputy ids carrying it — for named party filters. */ + stateDeputyIDsByParty: ReadonlyMap + /** StateDeputy ids that carry any party — for "Sem partido". */ + allPartyStateDeputyIDs: ReadonlySet +} + 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 = ( + raw: string | string[] | undefined, + sentinel: Sentinel, +): Array => { + const ids: number[] = [] + const seen = new Set() + 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() + 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(Object.keys(politicalTrendLabels)) const territorialClassSet = new Set(Object.keys(territorialClassLabels)) const engagementLevelFilterSet = new Set(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 = ( + byKey: ReadonlyMap, + 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). diff --git a/src/utilities/municipality/municipalityMapData.ts b/src/utilities/municipality/municipalityMapData.ts index ecfe13fc..091b2146 100644 --- a/src/utilities/municipality/municipalityMapData.ts +++ b/src/utilities/municipality/municipalityMapData.ts @@ -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 diff --git a/src/utilities/municipality/municipalityOmnibox.ts b/src/utilities/municipality/municipalityOmnibox.ts index 2eb7bf26..7790b885 100644 --- a/src/utilities/municipality/municipalityOmnibox.ts +++ b/src/utilities/municipality/municipalityOmnibox.ts @@ -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 + stateDeputyLabelsById: ReadonlyMap + leadershipLabelsById: ReadonlyMap }): 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, ) diff --git a/src/utilities/municipality/municipalityPageData.ts b/src/utilities/municipality/municipalityPageData.ts index 17c7e53e..64b4c363 100644 --- a/src/utilities/municipality/municipalityPageData.ts +++ b/src/utilities/municipality/municipalityPageData.ts @@ -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 +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 } | null, ): Promise => { @@ -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() + for (const [party, stateDeputyIDs] of relationCatalog.stateDeputyIDsByParty) { + for (const id of stateDeputyIDs) partyOfStateDeputyID.set(id, party) + } + const rowsByWhere = new Map>() if (loadedScope) { rowsByWhere.set(JSON.stringify(loadedScope.where), loadedScope.rows.then(applyClassFilter)) } const facetRows = (omit: Partial): Promise => { - 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() + for (const value of state.stateDeputies ?? []) { + if (typeof value === 'number') selectedStateDeputyIDs.add(value) + } + const selectedLeadershipIDs = new Set() + 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([ ...regionRows.map((row) => row.region), @@ -170,12 +209,57 @@ const loadMunicipalityListFilterFacets = async ( } } + const availableStateDeputyIDs = new Set(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(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(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 => { 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, diff --git a/src/utilities/municipality/municipalityRelationSets.ts b/src/utilities/municipality/municipalityRelationSets.ts new file mode 100644 index 00000000..2bce61b0 --- /dev/null +++ b/src/utilities/municipality/municipalityRelationSets.ts @@ -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 => { + 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() + const allLeadershipMunicipalityIDs = new Set() + 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() + const allPartyStateDeputyIDs = new Set() + 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, + } +} diff --git a/src/utilities/stateDeputyData.ts b/src/utilities/stateDeputyData.ts index da8d0c69..be1e3574 100644 --- a/src/utilities/stateDeputyData.ts +++ b/src/utilities/stateDeputyData.ts @@ -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, diff --git a/src/utilities/stateDeputyListFilters.ts b/src/utilities/stateDeputyListFilters.ts index 38958099..d220c0a1 100644 --- a/src/utilities/stateDeputyListFilters.ts +++ b/src/utilities/stateDeputyListFilters.ts @@ -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, diff --git a/src/utilities/stateDeputyListUrl.ts b/src/utilities/stateDeputyListUrl.ts index 94d86260..d88e629a 100644 --- a/src/utilities/stateDeputyListUrl.ts +++ b/src/utilities/stateDeputyListUrl.ts @@ -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 } : {} diff --git a/src/utilities/stateDeputyOmnibox.ts b/src/utilities/stateDeputyOmnibox.ts index b204949c..920c2463 100644 --- a/src/utilities/stateDeputyOmnibox.ts +++ b/src/utilities/stateDeputyOmnibox.ts @@ -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, diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index 9461e6d8..f8d12969 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -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, diff --git a/tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts b/tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts index 55f428c1..0b19f6e5 100644 --- a/tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts @@ -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) }) diff --git a/tests/e2e/campaignWebAuthn.e2e.spec.ts b/tests/e2e/campaignWebAuthn.e2e.spec.ts index db6d0b59..8cf2561c 100644 --- a/tests/e2e/campaignWebAuthn.e2e.spec.ts +++ b/tests/e2e/campaignWebAuthn.e2e.spec.ts @@ -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() diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index 372e8616..ddff0997 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -425,6 +425,7 @@ class CampaignE2EOwnership { levelNote: null, strengths: [], risks: [], + stateDeputies: [], dobradinhaNotes: null, nextSteps: null, lastUpdateAt: null, diff --git a/tests/int/campaignEntityListData.int.spec.ts b/tests/int/campaignEntityListData.int.spec.ts index 37c1a6b2..cc179118 100644 --- a/tests/int/campaignEntityListData.int.spec.ts +++ b/tests/int/campaignEntityListData.int.spec.ts @@ -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' diff --git a/tests/int/municipalityListRelationFilters.int.spec.ts b/tests/int/municipalityListRelationFilters.int.spec.ts new file mode 100644 index 00000000..c78a76f7 --- /dev/null +++ b/tests/int/municipalityListRelationFilters.int.spec.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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) + }) +}) diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index e9b5fcc7..bdb41c26 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -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 `` 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 `` 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('')) - 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({ 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 `` 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', diff --git a/tests/unit/municipalityList.unit.spec.ts b/tests/unit/municipalityList.unit.spec.ts index 5fba7a05..dfed5017 100644 --- a/tests/unit/municipalityList.unit.spec.ts +++ b/tests/unit/municipalityList.unit.spec.ts @@ -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 = {} + 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')), + ) + }) +}) diff --git a/tests/unit/municipalityOmnibox.unit.spec.ts b/tests/unit/municipalityOmnibox.unit.spec.ts index e84a73f6..1bab79cc 100644 --- a/tests/unit/municipalityOmnibox.unit.spec.ts +++ b/tests/unit/municipalityOmnibox.unit.spec.ts @@ -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, diff --git a/tests/unit/stateDeputyListUrl.unit.spec.ts b/tests/unit/stateDeputyListUrl.unit.spec.ts index 5fa30610..59489f59 100644 --- a/tests/unit/stateDeputyListUrl.unit.spec.ts +++ b/tests/unit/stateDeputyListUrl.unit.spec.ts @@ -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,