diff --git a/docs/plans/assessores-omnibox-municipio-carteira-impl.md b/docs/plans/assessores-omnibox-municipio-carteira-impl.md new file mode 100644 index 00000000..e269fd94 --- /dev/null +++ b/docs/plans/assessores-omnibox-municipio-carteira-impl.md @@ -0,0 +1,76 @@ +# Impl: Assessores — filtrar por município da carteira na omnibox + +Status: aprovado +Atualizado em: 2026-08-02 +Issue: #310 +Intenção: docs/plans/assessores-omnibox-municipio-carteira.md +Appetite restante: ~0,5 dia eng + +## Leitura da intenção + +- **Outcome:** Coordenador/Candidato filtra assessores pela carteira (município administrado) via omnibox, com busca por nome/e-mail preservada e chips removíveis. +- **O que NÃO negociar:** rota continua `unrestricted`; sem filtro “sem carteira”; sem saved filters; sem mudança de RBAC. +- **O que reavaliar:** sair de `searchOnlyListOmnibox` — confirmado; relação assessor↔município é inversa (`municipality.advisors`), não campo em `campaignUser`. + +## Abordagem recomendada + +```mermaid +flowchart LR + URL["URL ?municipality=&q="] --> Parse["advisorListUrl"] + Parse --> Load["loadAdvisorListPageData"] + Load --> Resolve["advisorIds via municipality.advisors"] + Resolve --> Query["campaignUser role=advisor"] + Index["municipalityPortfolioIndex + catalog"] --> Omnibox["advisorOmnibox"] + Omnibox --> UI["AdvisorFilters"] +``` + +**Opções consideradas:** + +- A) Estender `searchOnlyListOmnibox` com hooks — rejeitada (não escala; leadership/supporter já têm adapter próprio). +- B) Adapter `advisorOmnibox` + `advisorListFilters` espelhando lideranças (só dimensão município + busca) — **recomendada**. +- C) Facet server-side de municípios com assessor — rejeitada nesta fatia (busca tipada no índice de 435 já atende; página já carrega `loadMunicipalityPortfolioIndex`). + +**Recomendação:** B — reusa contrato URL multi-`municipality`, toggles OR, grupo omnibox “Município (carteira)”, filtro server via união de `municipality.advisors`. + +### Componentes / mudanças + +- **`advisorListUrl.ts`:** `municipalities?: number[]`; parse/serialize `municipality` repetível. +- **`advisorListFilters.ts` (novo):** toggle/clear município; `clearAdvisorListFilters`. +- **`advisorOmnibox.ts` (novo):** chips, seeds, apply/remove — grupo “Município (carteira)”. +- **`advisorData.ts`:** resolver IDs de assessor a partir dos municípios selecionados (OR); short-circuit lista vazia. +- **`AdvisorFilters.tsx`:** props `municipalityFilterOptions`; placeholder/label atualizados. +- **`assessores/page.tsx`:** montar opções do índice + catalog; `hasQuery` inclui municípios. +- **Testes:** parser/href unit; omnibox toggle unit; int `loadAdvisorListPageData` com filtro município. + +### Dados → forma + +- Opções omnibox: `loadMunicipalityPortfolioIndex` + nome do `municipalityCatalog` (já na página) — busca tipada, sem facet extra. + +## Fases verificáveis + +1. **URL + server** — parse, href, filtro Payload, testes unit/int. +2. **UI** — `AdvisorFilters` + omnibox adapter. +3. **Gates** — `pnpm gate:fast`; push via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Filtro “sem carteira” / cobertura estadual. +- Saved filters / coluna head filter. +- Facet dinâmico restrito a municípios com assessor (defer: gatilho se omnibox ficar ruidosa). + +## Riscos e mitigação + +- **Performance:** uma query extra `municipality` só quando há filtro; índice omnibox client-side já cacheado. +- **OR vs AND:** toggles multi-valor + `id in union(advisors)` — alinhado a lideranças. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto +- [x] Invariantes AGENTS/engineering-standards +- [x] Testes de domínio previstos (unit/int) onde access/write paths mudam + +## Débitos deferidos (simplify) + +- Facet server-side restrito a municípios com assessor — gatilho: omnibox ruidosa com 435 seeds. +- Toggle municipality via parse round-trip — gatilho: segunda dimensão de filtro em assessores. +- Omnibox `municipality:` slice duplicado em apply/remove — gatilho: refactor cross-list omnibox. diff --git a/src/app/(campaign)/campanha/(app)/assessores/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/page.tsx index d06c0e5c..87dc3c96 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/page.tsx @@ -11,6 +11,8 @@ import { } from '@/components/campaign/shared/CampaignListPending' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog' +import { hasAdvisorListActiveFilters } from '@/utilities/advisor/advisorListFilters' import { advisorListHrefForPage, loadAdvisorListPageData, @@ -46,16 +48,23 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) loadMunicipalityPortfolioIndex(), ]) + const municipalityFilterOptions = municipalityIndex.map((entry) => ({ + value: String(entry.id), + label: getMunicipalityCatalogEntry(entry.slug)?.name ?? entry.slug, + })) + + const hasActiveFilters = hasAdvisorListActiveFilters(state) + return ( - + { +export const AdvisorFilters = ({ + state, + municipalityFilterOptions, +}: { + state: AdvisorListState + municipalityFilterOptions: AdvisorFilterOption[] +}) => { const { navigate, isPending } = useCampaignListFilterNavigation({ state, toHref: (next) => advisorListHrefForPage(next, 1), }) const [query, setQuery] = useState('') - const withPageReset = (next: AdvisorListState): AdvisorListState => ({ ...next, page: 1 }) + const municipalityLabelsById = useMemo(() => { + const map = new Map() + for (const option of municipalityFilterOptions) { + const id = Number(option.value) + if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label) + } + return map + }, [municipalityFilterOptions]) - const chips = useMemo(() => buildSearchOnlyOmniboxChips(state), [state]) + const chips = useMemo( + () => buildAdvisorOmniboxChips({ state, municipalityLabelsById }), + [state, municipalityLabelsById], + ) - const suggestions = useMemo(() => buildSearchOnlyOmniboxSuggestions(query), [query]) + const suggestionSeeds = useMemo( + () => buildAdvisorOmniboxSuggestionSeeds({ municipalityFilterOptions }), + [municipalityFilterOptions], + ) - const runAction = (action: SearchOnlyOmniboxAction) => { + const suggestions = useMemo( + () => filterAdvisorOmniboxSuggestions(suggestionSeeds, query), + [suggestionSeeds, query], + ) + + const runAction = (action: AdvisorOmniboxAction) => { if (action.kind === 'clear') { setQuery('') navigate(action.state) @@ -45,41 +71,24 @@ export const AdvisorFilters = ({ state }: { state: AdvisorListState }) => { > { - runAction( - applySearchOnlyOmniboxSuggestion({ - state, - suggestionId, - withPageReset, - }), - ) + runAction(applyAdvisorOmniboxSuggestion({ state, suggestionId })) }} onCommitQuery={(text) => { - runAction( - applySearchOnlyOmniboxSuggestion({ - state, - suggestionId: `q:${text}`, - withPageReset, - }), - ) + runAction(applyAdvisorOmniboxSuggestion({ state, suggestionId: `q:${text}` })) }} onRemoveChip={(chipId) => { - runAction(removeSearchOnlyOmniboxChip({ state, chipId, withPageReset })) + runAction(removeAdvisorOmniboxChip({ state, chipId })) }} onClearAll={() => { - runAction( - clearSearchOnlyOmnibox({ - state, - cleared: { page: 1 }, - }), - ) + runAction(clearAdvisorOmnibox(state)) }} /> diff --git a/src/lib/searchOnlyListOmnibox.ts b/src/lib/searchOnlyListOmnibox.ts index c60d8931..ef35f92b 100644 --- a/src/lib/searchOnlyListOmnibox.ts +++ b/src/lib/searchOnlyListOmnibox.ts @@ -1,6 +1,6 @@ /** * Degenerate omnibox for lists that only expose free-text search (B128: - * organizações, assessores). Pure / client-safe. + * organizações). Pure / client-safe. */ import { filterOmniboxSuggestionSeeds, diff --git a/src/utilities/advisor/advisorListFilters.ts b/src/utilities/advisor/advisorListFilters.ts new file mode 100644 index 00000000..63b06ae1 --- /dev/null +++ b/src/utilities/advisor/advisorListFilters.ts @@ -0,0 +1,46 @@ +/** + * Advisor list filter affordances: município (carteira) toggles and clear. + */ +import { + advisorListStateToRawParams, + parseAdvisorListParams, + type AdvisorListState, +} from '@/utilities/advisor/advisorListUrl' +import { strictDecimalInteger } from '@/utilities/campaignListUrl' + +export type AdvisorFilterOption = { + value: string + label: string +} + +const setAdvisorMunicipalityFilterValues = ( + state: AdvisorListState, + values: string[], +): AdvisorListState => + parseAdvisorListParams({ + ...advisorListStateToRawParams({ ...state, page: 1 }, 1), + municipality: values, + }) + +const getAdvisorMunicipalityFilterValues = (state: AdvisorListState): string[] => + (state.municipalities ?? []).map(String) + +export const toggleAdvisorMunicipalityFilter = ( + state: AdvisorListState, + value: string, +): AdvisorListState => { + if (strictDecimalInteger(value) === undefined) return state + const current = getAdvisorMunicipalityFilterValues(state) + return setAdvisorMunicipalityFilterValues( + state, + current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value], + ) +} + +/** Drop every filter and the search; keep pagination at page 1. */ +export const clearAdvisorListFilters = (): AdvisorListState => ({ + page: 1, +}) + +export const hasAdvisorListActiveFilters = (state: AdvisorListState): boolean => + Boolean(state.q || state.municipalities?.length) diff --git a/src/utilities/advisor/advisorListUrl.ts b/src/utilities/advisor/advisorListUrl.ts index d5391e94..675ee52a 100644 --- a/src/utilities/advisor/advisorListUrl.ts +++ b/src/utilities/advisor/advisorListUrl.ts @@ -1,5 +1,6 @@ import { CAMPAIGN_ADVISORS_HOME } from '@/lib/campaignPaths' import { + allParamValues, buildListHref, firstValue, normalizedText, @@ -10,21 +11,40 @@ import { export type AdvisorListState = { page: number q?: string + municipalities?: number[] } +export type AdvisorListSearchParams = RawSearchParams + +export const advisorListStateToRawParams = ( + state: AdvisorListState, + page = state.page, +): AdvisorListSearchParams => ({ + page: String(page), + q: state.q, + municipality: state.municipalities?.map(String), +}) + export const parseAdvisorListParams = (searchParams: RawSearchParams): AdvisorListState => { const q = normalizedText(firstValue(searchParams.q)) const page = strictDecimalInteger(firstValue(searchParams.page)) ?? 1 + const municipalities = allParamValues(searchParams.municipality) + .map((token) => strictDecimalInteger(token)) + .filter((id): id is number => typeof id === 'number' && id > 0) return { page, ...(q ? { q } : {}), + ...(municipalities.length ? { municipalities } : {}), } } const buildAdvisorListSearchParams = (state: AdvisorListState, page = state.page) => { const params = new URLSearchParams() if (state.q) params.set('q', state.q) + for (const municipality of state.municipalities ?? []) { + params.append('municipality', String(municipality)) + } if (page > 1) params.set('page', String(page)) return params } diff --git a/src/utilities/advisor/advisorOmnibox.ts b/src/utilities/advisor/advisorOmnibox.ts new file mode 100644 index 00000000..763820be --- /dev/null +++ b/src/utilities/advisor/advisorOmnibox.ts @@ -0,0 +1,121 @@ +/** + * Advisor list omnibox adapter (B142). Pure / client-safe. + */ +import { + createOmniboxSuggestionSeed, + filterOmniboxSuggestionSeeds, + type CampaignListOmniboxChip, + type CampaignListOmniboxSuggestion, +} from '@/lib/campaignListOmnibox' +import { + clearAdvisorListFilters, + toggleAdvisorMunicipalityFilter, + type AdvisorFilterOption, +} from '@/utilities/advisor/advisorListFilters' +import type { AdvisorListState } from '@/utilities/advisor/advisorListUrl' + +export type AdvisorOmniboxAction = + | { kind: 'url'; state: AdvisorListState } + | { kind: 'clear'; state: AdvisorListState } + +const MUNICIPALITY_GROUP = 'Município (carteira)' + +const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}` + +const withPageReset = (state: AdvisorListState): AdvisorListState => ({ ...state, page: 1 }) + +export const buildAdvisorOmniboxChips = ({ + state, + municipalityLabelsById, +}: { + state: AdvisorListState + municipalityLabelsById: ReadonlyMap +}): CampaignListOmniboxChip[] => { + const chips: CampaignListOmniboxChip[] = [] + + if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) }) + + for (const municipalityId of state.municipalities ?? []) { + chips.push({ + id: `municipality:${municipalityId}`, + label: chipLabel( + MUNICIPALITY_GROUP, + municipalityLabelsById.get(municipalityId) ?? `Município #${municipalityId}`, + ), + }) + } + + return chips +} + +export const buildAdvisorOmniboxSuggestionSeeds = ({ + municipalityFilterOptions, +}: { + municipalityFilterOptions: readonly AdvisorFilterOption[] +}) => { + const seeds = [] + + for (const option of municipalityFilterOptions) { + seeds.push( + createOmniboxSuggestionSeed({ + id: `municipality:${option.value}`, + group: MUNICIPALITY_GROUP, + label: option.label, + keywords: ['municipio', 'carteira'], + }), + ) + } + + return seeds +} + +export const filterAdvisorOmniboxSuggestions = ( + seeds: ReturnType, + query: string, +): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query) + +export const applyAdvisorOmniboxSuggestion = ({ + state, + suggestionId, +}: { + state: AdvisorListState + suggestionId: string +}): AdvisorOmniboxAction => { + if (suggestionId.startsWith('q:')) { + const q = suggestionId.slice(2) + return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) } + } + + if (suggestionId.startsWith('municipality:')) { + return { + kind: 'url', + state: toggleAdvisorMunicipalityFilter(state, suggestionId.slice(13)), + } + } + + return { kind: 'url', state } +} + +export const removeAdvisorOmniboxChip = ({ + state, + chipId, +}: { + state: AdvisorListState + chipId: string +}): AdvisorOmniboxAction => { + if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) } + + if (chipId.startsWith('municipality:')) { + return { + kind: 'url', + state: toggleAdvisorMunicipalityFilter(state, chipId.slice(13)), + } + } + + return { kind: 'url', state } +} + +export const clearAdvisorOmnibox = (_state: AdvisorListState): AdvisorOmniboxAction => ({ + kind: 'clear', + state: clearAdvisorListFilters(), +}) diff --git a/src/utilities/advisorData.ts b/src/utilities/advisorData.ts index 4c281065..c0258856 100644 --- a/src/utilities/advisorData.ts +++ b/src/utilities/advisorData.ts @@ -40,6 +40,36 @@ export type AdvisorDetailViewModel = AdvisorAccountViewModel & { const advisorPageSize = 25 +/** Union of advisors linked on any of the given municipalities (OR semantics). */ +const advisorIdsForMunicipalityFilter = async ( + payload: Payload, + municipalityIds: number[], +): Promise => { + if (municipalityIds.length === 0) return [] + + // Intentional admin bypass: same rationale as `municipalityIdsByAdvisorIds` — the + // unrestricted route gate already asserted staff; this read only resolves filter ids. + const municipalities = await payload.find({ + collection: 'municipality', + where: { id: { in: municipalityIds } }, + depth: 0, + limit: 0, + pagination: false, + select: { advisors: true }, + overrideAccess: true, + }) + + const advisorIds = new Set() + for (const municipality of municipalities.docs) { + for (const advisor of municipality.advisors ?? []) { + const id = relationshipId(advisor) + if (id !== null) advisorIds.add(id) + } + } + + return [...advisorIds] +} + /** Fan a município out to each of the advisors the caller asked about. */ const collectByAdvisor = ( advisors: readonly unknown[] | null | undefined, @@ -133,11 +163,20 @@ export const loadAdvisorListPageData = async ( payload: Payload, state: AdvisorListState, ): Promise<{ rows: AdvisorRowViewModel[]; totalDocs: number; totalPages: number }> => { + const municipalityAdvisorIds = state.municipalities?.length + ? await advisorIdsForMunicipalityFilter(payload, state.municipalities) + : undefined + + if (municipalityAdvisorIds && municipalityAdvisorIds.length === 0) { + return { rows: [], totalDocs: 0, totalPages: 0 } + } + const result = await payload.find({ collection: 'campaignUser', where: { and: [ { role: { equals: 'advisor' } }, + ...(municipalityAdvisorIds ? [{ id: { in: municipalityAdvisorIds } }] : []), ...(state.q ? [ { diff --git a/tests/int/campaignEntityListData.int.spec.ts b/tests/int/campaignEntityListData.int.spec.ts index 0b5c4d84..94f8a79b 100644 --- a/tests/int/campaignEntityListData.int.spec.ts +++ b/tests/int/campaignEntityListData.int.spec.ts @@ -543,4 +543,34 @@ describe('loadAdvisorListPageData', () => { // Ids only: the list's chips resolve their labels from the static catalog. expect(result.rows[0]!.municipalityIDs).toEqual([municipality.id]) }) + + it('narrows by municipality portfolio with OR semantics', async () => { + const fixtures = campaignFixtures() + const advisorA = await fixtures.createCampaignUser('advisor') + const advisorB = await fixtures.createCampaignUser('advisor') + const municipalityA = await fixtures.getMunicipality() + const municipalityB = await fixtures.getMunicipality() + await fixtures.assignMunicipalityAdvisors(municipalityA.id, [advisorA.id]) + await fixtures.assignMunicipalityAdvisors(municipalityB.id, [advisorB.id]) + + const onlyA = await loadAdvisorListPageData(payload, { + page: 1, + municipalities: [municipalityA.id], + }) + expect(onlyA.totalDocs).toBe(1) + expect(onlyA.rows[0]?.id).toBe(advisorA.id) + + const either = await loadAdvisorListPageData(payload, { + page: 1, + municipalities: [municipalityA.id, municipalityB.id], + }) + expect(either.totalDocs).toBe(2) + expect(either.rows.map((row) => row.id).sort()).toEqual([advisorA.id, advisorB.id].sort()) + + const none = await loadAdvisorListPageData(payload, { + page: 1, + municipalities: [999_999], + }) + expect(none.totalDocs).toBe(0) + }) }) diff --git a/tests/unit/campaignEntityListParsers.unit.spec.ts b/tests/unit/campaignEntityListParsers.unit.spec.ts index c7ac71e8..452da041 100644 --- a/tests/unit/campaignEntityListParsers.unit.spec.ts +++ b/tests/unit/campaignEntityListParsers.unit.spec.ts @@ -31,6 +31,14 @@ describe.each(qAndPageParsers)('%s list parser (q + page)', (_name, parse) => { expect(parse({ q: ' ' }).q).toBeUndefined() expect(parse({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro') }) + + it('parses repeatable municipality ids and drops invalid tokens', () => { + expect(parseAdvisorListParams({ municipality: ['12', 'bad', '0'] }).municipalities).toEqual([ + 12, + ]) + expect(parseAdvisorListParams({ municipality: ['3', '7'] }).municipalities).toEqual([3, 7]) + expect(parseAdvisorListParams({}).municipalities).toBeUndefined() + }) }) describe('parseOrganizationListParams', () => { @@ -73,4 +81,13 @@ describe('advisorListHrefForPage', () => { '/campanha/assessores?q=edi&page=3', ) }) + + it('serializes repeatable municipality filters', () => { + expect(advisorListHrefForPage({ page: 1, municipalities: [4, 9] }, 1)).toBe( + '/campanha/assessores?municipality=4&municipality=9', + ) + expect(advisorListHrefForPage({ page: 1, q: 'ana', municipalities: [2] }, 2)).toBe( + '/campanha/assessores?q=ana&municipality=2&page=2', + ) + }) }) diff --git a/tests/unit/listOmniboxB128.unit.spec.ts b/tests/unit/listOmniboxB128.unit.spec.ts index 59f1c737..836311c5 100644 --- a/tests/unit/listOmniboxB128.unit.spec.ts +++ b/tests/unit/listOmniboxB128.unit.spec.ts @@ -10,6 +10,13 @@ import { clearActivityOmnibox, } from '@/utilities/activityOmnibox' import { parseActivityListParams } from '@/utilities/activityUi' +import { parseAdvisorListParams } from '@/utilities/advisor/advisorListUrl' +import { + applyAdvisorOmniboxSuggestion, + buildAdvisorOmniboxChips, + buildAdvisorOmniboxSuggestionSeeds, + filterAdvisorOmniboxSuggestions, +} from '@/utilities/advisor/advisorOmnibox' import { parseDemandListParams } from '@/utilities/demand/demandListUrl' import { applyDemandOmniboxSuggestion, @@ -258,4 +265,28 @@ describe('list omnibox adapters (B128)', () => { }) expect(applied).toEqual({ kind: 'url', state: { page: 1, q: 'ana' } }) }) + + it('advisor toggles municipality portfolio filter and suggests by name', () => { + const base = parseAdvisorListParams({}) + const applied = applyAdvisorOmniboxSuggestion({ + state: base, + suggestionId: 'municipality:42', + }) + expect(applied.kind).toBe('url') + if (applied.kind === 'url') { + expect(applied.state.municipalities).toEqual([42]) + } + + const chips = buildAdvisorOmniboxChips({ + state: parseAdvisorListParams({ municipality: '42' }), + municipalityLabelsById: new Map([[42, 'Feira de Santana']]), + }) + expect(chips[0]?.label).toContain('Feira de Santana') + + const seeds = buildAdvisorOmniboxSuggestionSeeds({ + municipalityFilterOptions: [{ value: '42', label: 'Feira de Santana' }], + }) + const suggestions = filterAdvisorOmniboxSuggestions(seeds, 'feira') + expect(suggestions.some((entry) => entry.group === 'Município (carteira)')).toBe(true) + }) })