diff --git a/src/app/(campaign)/campanha/(app)/home-search/route.ts b/src/app/(campaign)/campanha/(app)/home-search/route.ts index 1e46b29b..77977a3b 100644 --- a/src/app/(campaign)/campanha/(app)/home-search/route.ts +++ b/src/app/(campaign)/campanha/(app)/home-search/route.ts @@ -12,6 +12,7 @@ import { homeSearchBodySchema } from '@/lib/schemas/homeSearch' import { getCampaignActionContext } from '@/utilities/campaignActionContext' import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearchSuggestions' +import { loadWizardMunicipalitySuggestions } from '@/utilities/homeSearch/loadWizardMunicipalitySuggestions' import { searchHomeActivities } from '@/utilities/homeSearch/searchHomeActivities' import { searchHomeAdvisors } from '@/utilities/homeSearch/searchHomeAdvisors' import { searchHomeDemands } from '@/utilities/homeSearch/searchHomeDemands' @@ -22,23 +23,32 @@ import { searchStaffMunicipalityHits } from '@/utilities/homeSearch/searchStaffM export const dynamic = 'force-dynamic' +type HomeSearchRouteSuccessBody = + | HomeSearchSuccessResponse + | WizardMunicipalitySearchSuccessResponse + export const POST = campaignJsonMutationRoute( { bodySchema: homeSearchBodySchema, safeMessages: [HOME_SEARCH_STAFF_ONLY_MESSAGE], genericMessage: HOME_SEARCH_GENERIC_ERROR_MESSAGE, }, - async (body) => { + async (body): Promise> => { const { payload, actor } = await getCampaignActionContext() if (body.mode === 'suggest') { const result = await loadHomeSearchSuggestions(payload, actor) - return NextResponse.json(result) + return NextResponse.json(result) + } + + if (body.mode === 'wizard-municipality-suggest') { + const result = await loadWizardMunicipalitySuggestions(payload, actor) + return NextResponse.json(result) } if (body.mode === 'wizard-municipality') { const municipalities = await searchStaffMunicipalityHits(payload, actor, body.query) - return NextResponse.json({ + return NextResponse.json({ status: 'success', municipalities, }) @@ -53,7 +63,7 @@ export const POST = campaignJsonMutationRoute( searchHomeStateDeputies(payload, actor, body.query), searchHomeDemands(payload, actor, body.query), ]) - return NextResponse.json({ + return NextResponse.json({ ...municipalityResult, advisors, leaderships, diff --git a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx index b86c5034..f9904be5 100644 --- a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx +++ b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx @@ -44,10 +44,12 @@ export const WizardMunicipalitySearchStep = ({ const { query, setRaw, isDebouncing } = useHomeSearchQuery() const [results, setResults] = useState({ status: 'idle' }) const requestSeq = useRef(0) + const suggestMode = !query.isActive + const searchMode = query.isActive const resultsBusy = isDebouncing || results.status === 'loading' useEffect(() => { - if (!query.isActive) { + if (!suggestMode && !searchMode) { requestSeq.current += 1 setResults({ status: 'idle' }) return @@ -57,10 +59,14 @@ export const WizardMunicipalitySearchStep = ({ const controller = new AbortController() setResults({ status: 'loading' }) + const body = suggestMode + ? ({ mode: 'wizard-municipality-suggest' } as const) + : ({ mode: 'wizard-municipality', query: query.debounced } as const) + void (async () => { const { ok, payload } = await postCampaignJson( HOME_SEARCH_ROUTE, - { mode: 'wizard-municipality', query: query.debounced }, + body, controller.signal, ) @@ -81,7 +87,7 @@ export const WizardMunicipalitySearchStep = ({ return () => { controller.abort() } - }, [query.debounced, query.isActive]) + }, [query.debounced, searchMode, suggestMode]) const showEmpty = query.isActive && results.status === 'success' && results.municipalities.length === 0 diff --git a/src/lib/campaignHomeSearchHits.ts b/src/lib/campaignHomeSearchHits.ts index b7bc2cff..6399c8f8 100644 --- a/src/lib/campaignHomeSearchHits.ts +++ b/src/lib/campaignHomeSearchHits.ts @@ -118,5 +118,6 @@ export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean = export type WizardMunicipalitySearchSuccessResponse = { status: 'success' + resultKind?: 'wizard-suggest' municipalities: HomeSearchMunicipalityHit[] } diff --git a/src/lib/schemas/homeSearch.ts b/src/lib/schemas/homeSearch.ts index e6f4fcd4..6a14fa11 100644 --- a/src/lib/schemas/homeSearch.ts +++ b/src/lib/schemas/homeSearch.ts @@ -22,4 +22,7 @@ export const homeSearchBodySchema = z.discriminatedUnion('mode', [ mode: z.literal('wizard-municipality'), query: homeSearchQueryField, }), + z.object({ + mode: z.literal('wizard-municipality-suggest'), + }), ]) diff --git a/src/lib/wizardMunicipalitySuggest.ts b/src/lib/wizardMunicipalitySuggest.ts new file mode 100644 index 00000000..d7008794 --- /dev/null +++ b/src/lib/wizardMunicipalitySuggest.ts @@ -0,0 +1,122 @@ +import { engagementLevelRank, type EngagementLevel } from '@/lib/engagementLevel' +import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest' +import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality' +import type { MunicipalityTerritorialClass } from '@/lib/territorialClassAnchors' +import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight' + +export type WizardMunicipalitySuggestInput = { + slug: string + name: string + /** ISO timestamp or null when never signaled — colder sorts first. */ + lastSignalAt: string | null + engagementLevel: EngagementLevel | null + politicalTrend: PoliticalTrendStatusValue | null + territorialClass: MunicipalityTerritorialClass + /** Nominal 2022 candidate votes; null when absent from artifact. */ + votes2022: number | null +} + +/** Higher = more urgent for wizard tie-break (desfavorável first). */ +const politicalTrendSortWeight: Record = { + desfavoravel: 3, + neutra: 2, + favoravel: 1, +} + +const municipalityNameCompare = ( + left: WizardMunicipalitySuggestInput, + right: WizardMunicipalitySuggestInput, +): number => left.name.localeCompare(right.name, 'pt-BR') + +const compareEngagementLevel = ( + left: WizardMunicipalitySuggestInput, + right: WizardMunicipalitySuggestInput, +): number => { + const leftRank = left.engagementLevel === null ? -1 : engagementLevelRank[left.engagementLevel] + const rightRank = right.engagementLevel === null ? -1 : engagementLevelRank[right.engagementLevel] + if (leftRank !== rightRank) return rightRank - leftRank + return 0 +} + +const comparePoliticalTrend = ( + left: WizardMunicipalitySuggestInput, + right: WizardMunicipalitySuggestInput, +): number => { + const leftWeight = + left.politicalTrend === null ? 0 : politicalTrendSortWeight[left.politicalTrend] + const rightWeight = + right.politicalTrend === null ? 0 : politicalTrendSortWeight[right.politicalTrend] + if (leftWeight !== rightWeight) return rightWeight - leftWeight + return 0 +} + +const compareTerritorialClass = ( + left: WizardMunicipalitySuggestInput, + right: WizardMunicipalitySuggestInput, +): number => { + const leftWeight = territorialClassSortWeight[left.territorialClass] + const rightWeight = territorialClassSortWeight[right.territorialClass] + if (leftWeight === null && rightWeight === null) return 0 + if (leftWeight === null) return 1 + if (rightWeight === null) return -1 + if (leftWeight !== rightWeight) return rightWeight - leftWeight + return 0 +} + +const compareVotes2022 = ( + left: WizardMunicipalitySuggestInput, + right: WizardMunicipalitySuggestInput, +): number => { + const leftVotes = left.votes2022 + const rightVotes = right.votes2022 + if (leftVotes === null && rightVotes === null) return 0 + if (leftVotes === null || leftVotes === 0) return 1 + if (rightVotes === null || rightVotes === 0) return -1 + if (leftVotes !== rightVotes) return rightVotes - leftVotes + return 0 +} + +/** Older signal first; null = coldest; equal timestamps defer to later tie-breakers. */ +const compareSignalOldestFirst = ( + left: WizardMunicipalitySuggestInput, + right: WizardMunicipalitySuggestInput, +): number => { + if (left.lastSignalAt === null && right.lastSignalAt === null) return 0 + if (left.lastSignalAt === null) return -1 + if (right.lastSignalAt === null) return 1 + const byTime = left.lastSignalAt.localeCompare(right.lastSignalAt) + if (byTime !== 0) return byTime + return 0 +} + +const compareWizardMunicipalitySuggest = ( + left: WizardMunicipalitySuggestInput, + right: WizardMunicipalitySuggestInput, +): number => { + const bySignal = compareSignalOldestFirst(left, right) + if (bySignal !== 0) return bySignal + + const byEngagement = compareEngagementLevel(left, right) + if (byEngagement !== 0) return byEngagement + + const byTrend = comparePoliticalTrend(left, right) + if (byTrend !== 0) return byTrend + + const byClass = compareTerritorialClass(left, right) + if (byClass !== 0) return byClass + + const byVotes = compareVotes2022(left, right) + if (byVotes !== 0) return byVotes + + return municipalityNameCompare(left, right) +} + +/** + * B92 — wizard municipality idle suggestions: coldest signal first, then + * operational tie-breakers (E14 → trend → E10 class → 2022 votes → name). + */ +export const rankWizardMunicipalitySuggestions = ( + municipalities: WizardMunicipalitySuggestInput[], + limit = HOME_SEARCH_SUGGEST_LIMIT, +): WizardMunicipalitySuggestInput[] => + [...municipalities].sort(compareWizardMunicipalitySuggest).slice(0, limit) diff --git a/src/utilities/homeSearch/loadWizardMunicipalitySuggestions.ts b/src/utilities/homeSearch/loadWizardMunicipalitySuggestions.ts new file mode 100644 index 00000000..a106bf12 --- /dev/null +++ b/src/utilities/homeSearch/loadWizardMunicipalitySuggestions.ts @@ -0,0 +1,69 @@ +import 'server-only' + +import type { Payload } from 'payload' + +import { + toHomeSearchMunicipalityHit, + type WizardMunicipalitySearchSuccessResponse, +} from '@/lib/campaignHomeSearchHits' +import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages' +import { isStaffCampaignRole } from '@/lib/campaignRoles' +import { DEFAULT_VOTE_RANK_YEAR, getMunicipalityVoteRank } from '@/lib/municipalityVoteRank' +import { + rankWizardMunicipalitySuggestions, + type WizardMunicipalitySuggestInput, +} from '@/lib/wizardMunicipalitySuggest' +import type { CampaignUser } from '@/payload-types' +import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope' +import { resolveMunicipalityLastSignalAt } from '@/utilities/municipality/municipalitySignal' +import { computeMunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass' + +export const loadWizardMunicipalitySuggestions = async ( + payload: Payload, + user: CampaignUser, +): Promise => { + if (!isStaffCampaignRole(user.role)) { + throw new Error(HOME_SEARCH_STAFF_ONLY_MESSAGE) + } + + const { municipalities, pledgeAggregates } = await loadMunicipalityScope( + payload, + user, + {}, + { + extraSelect: { + lastUpdateAt: true, + engagementLevel: true, + politicalTrend: true, + }, + }, + ) + + const inputs: WizardMunicipalitySuggestInput[] = municipalities.map((doc) => { + const aggregate = pledgeAggregates.get(doc.id) + const lastSignalAt = resolveMunicipalityLastSignalAt( + doc.lastUpdateAt ?? null, + aggregate?.lastPledgeAt ?? null, + ) + const voteRank = getMunicipalityVoteRank(doc.slug, DEFAULT_VOTE_RANK_YEAR) + + return { + slug: doc.slug, + name: doc.name, + lastSignalAt, + engagementLevel: doc.engagementLevel ?? null, + politicalTrend: doc.politicalTrend?.status ?? null, + territorialClass: computeMunicipalityTerritorialClass(doc.slug).class, + votes2022: voteRank?.votes ?? null, + } + }) + + const ranked = rankWizardMunicipalitySuggestions(inputs) + const docBySlug = new Map(municipalities.map((doc) => [doc.slug, doc])) + + return { + status: 'success', + resultKind: 'wizard-suggest', + municipalities: ranked.map((input) => toHomeSearchMunicipalityHit(docBySlug.get(input.slug)!)), + } +} diff --git a/tests/int/wizardMunicipalitySuggestions.int.spec.ts b/tests/int/wizardMunicipalitySuggestions.int.spec.ts new file mode 100644 index 00000000..841bc27f --- /dev/null +++ b/tests/int/wizardMunicipalitySuggestions.int.spec.ts @@ -0,0 +1,53 @@ +// @vitest-environment node + +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import { beforeAll, describe, expect, it } from 'vitest' + +import config from '@/payload.config' +import { loadWizardMunicipalitySuggestions } from '@/utilities/homeSearch/loadWizardMunicipalitySuggestions' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +describe('loadWizardMunicipalitySuggestions (B92)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('returns up to eight scoped municipalities for coordinator', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + + const result = await loadWizardMunicipalitySuggestions(payload, coordinator) + expect(result.resultKind).toBe('wizard-suggest') + expect(result.municipalities.length).toBeGreaterThan(0) + expect(result.municipalities.length).toBeLessThanOrEqual(8) + }) + + it('scopes advisor suggestions to administered municipalities', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const administered = await fixtures.getMunicipality() + await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) + + const result = await loadWizardMunicipalitySuggestions(payload, advisor) + expect(result.municipalities.every((hit) => hit.slug === administered.slug)).toBe(true) + }) + + it('rejects leaders from wizard municipality suggestions', async () => { + const fixtures = campaignFixtures() + const leader = await fixtures.createCampaignUser('leader') + + await expect(loadWizardMunicipalitySuggestions(payload, leader)).rejects.toThrow( + /equipe de campanha/i, + ) + }) +}) diff --git a/tests/unit/wizardMunicipalitySuggest.unit.spec.ts b/tests/unit/wizardMunicipalitySuggest.unit.spec.ts new file mode 100644 index 00000000..82b43f1a --- /dev/null +++ b/tests/unit/wizardMunicipalitySuggest.unit.spec.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' + +import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest' +import { + rankWizardMunicipalitySuggestions, + type WizardMunicipalitySuggestInput, +} from '@/lib/wizardMunicipalitySuggest' + +const row = ( + partial: Partial & + Pick, +): WizardMunicipalitySuggestInput => ({ + lastSignalAt: null, + engagementLevel: null, + politicalTrend: null, + territorialClass: 'manutencao', + votes2022: null, + ...partial, +}) + +describe('rankWizardMunicipalitySuggestions', () => { + it('ranks by oldest signal first, then name', () => { + const municipalities = [ + row({ slug: 'b', name: 'Brumado', lastSignalAt: '2026-07-20T12:00:00.000Z' }), + row({ slug: 'a', name: 'Abaíra', lastSignalAt: null }), + row({ slug: 'c', name: 'Cairu', lastSignalAt: '2026-07-01T12:00:00.000Z' }), + ] + + const ranked = rankWizardMunicipalitySuggestions(municipalities) + expect(ranked.map((m) => m.slug)).toEqual(['a', 'c', 'b']) + }) + + it('breaks signal ties by engagement level (higher first)', () => { + const municipalities = [ + row({ + slug: 'low', + name: 'Low', + lastSignalAt: '2026-07-01T12:00:00.000Z', + engagementLevel: 'n1', + }), + row({ + slug: 'high', + name: 'High', + lastSignalAt: '2026-07-01T12:00:00.000Z', + engagementLevel: 'n4', + }), + ] + + const ranked = rankWizardMunicipalitySuggestions(municipalities) + expect(ranked.map((m) => m.slug)).toEqual(['high', 'low']) + }) + + it('breaks engagement ties by political trend (desfavorável first)', () => { + const municipalities = [ + row({ + slug: 'fav', + name: 'Fav', + lastSignalAt: '2026-07-01T12:00:00.000Z', + engagementLevel: 'n2', + politicalTrend: 'favoravel', + }), + row({ + slug: 'des', + name: 'Des', + lastSignalAt: '2026-07-01T12:00:00.000Z', + engagementLevel: 'n2', + politicalTrend: 'desfavoravel', + }), + ] + + const ranked = rankWizardMunicipalitySuggestions(municipalities) + expect(ranked.map((m) => m.slug)).toEqual(['des', 'fav']) + }) + + it('breaks trend ties by territorial class (reduto before marginal)', () => { + const municipalities = [ + row({ + slug: 'marg', + name: 'Marg', + lastSignalAt: '2026-07-01T12:00:00.000Z', + territorialClass: 'marginal', + }), + row({ + slug: 'red', + name: 'Red', + lastSignalAt: '2026-07-01T12:00:00.000Z', + territorialClass: 'reduto', + }), + ] + + const ranked = rankWizardMunicipalitySuggestions(municipalities) + expect(ranked.map((m) => m.slug)).toEqual(['red', 'marg']) + }) + + it('breaks class ties by 2022 votes (higher first)', () => { + const municipalities = [ + row({ + slug: 'low', + name: 'Low', + lastSignalAt: '2026-07-01T12:00:00.000Z', + votes2022: 100, + }), + row({ + slug: 'high', + name: 'High', + lastSignalAt: '2026-07-01T12:00:00.000Z', + votes2022: 5000, + }), + ] + + const ranked = rankWizardMunicipalitySuggestions(municipalities) + expect(ranked.map((m) => m.slug)).toEqual(['high', 'low']) + }) + + it('caps results at HOME_SEARCH_SUGGEST_LIMIT', () => { + const municipalities = Array.from({ length: 12 }, (_, index) => + row({ + slug: `m-${index}`, + name: `Município ${index}`, + lastSignalAt: `2026-07-${String(index + 1).padStart(2, '0')}T12:00:00.000Z`, + }), + ) + + const ranked = rankWizardMunicipalitySuggestions(municipalities) + expect(ranked).toHaveLength(HOME_SEARCH_SUGGEST_LIMIT) + }) +})