teqo/src/utilities/municipalityViewModels.ts
Francisco Ignácio Fontoura Solla 11d3fcd24c Pass 2 W4e: tipos de fonte unica
- triplo de cenarios: municipalityMapData usa VoteEstimateScenarioFields (2 grafias inline eliminadas)
- MAX_LEADERSHIP_MUNICIPALITIES: Leadership.ts importa de lib/schemas/leadership (const local removida)
- campaignUserInitials: copia divergente do AvatarStack removida; fonte unica em campaignUserProfile (com fallback defensivo)
- tendencia politica: options do select da collection derivam de politicalTrendStatuses + politicalTrendLabels; ProjectionTrendStatus vira alias de PoliticalTrendStatusValue
- MunicipalityStrategyViewModel nomeado e exportado (consumidores param de indexar NonNullable<...['strategy']>); nota de por que NAO deriva do zod
- DynamicFind documentado como a unica grafia aprovada do cast
- stub<T> nos doubles de teste onde a checagem por propriedade e real (campaignUserPhone, campaignAuth, campaignConsent); deep fakes mantem o cast localizado
2026-07-25 15:05:03 -03:00

240 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'server-only'
import type { Payload } from 'payload'
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
import type { CampaignUser, Municipality } from '@/payload-types'
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
import { createEmptyGoalCoverageByScenario, type MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
import { type PoliticalTrendStatus } from '@/utilities/municipalityLabels'
import { resolveMunicipalityLastSignalAt } from '@/utilities/municipalitySignal'
import { relationshipId } from '@/utilities/relationship'
import type { StateDeputySummary } from '@/utilities/stateDeputyData'
import {
toVoteEstimateScenarioViewModel,
type VoteEstimateScenario,
type VoteEstimateScenarioViewModel,
} from '@/lib/voteEstimate'
import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
export const municipalityListSelect = {
name: true,
slug: true,
kind: true,
city: true,
region: true,
ibgeCode: true,
zoneNumber: true,
advisors: true,
priority: true,
lastUpdateAt: true,
expectedVotes: true,
politicalTrend: {
status: true,
note: true,
},
} as const
export type MunicipalityPoliticalTrendViewModel = {
status: PoliticalTrendStatus | null
note: string | null
recordedByName: string | null
recordedAt: string | null
}
export type MunicipalityListViewModel = {
id: number
name: string
slug: string
kind: Municipality['kind']
city: string
region: string
ibgeCode: string
zoneNumber: number | null
advisorIDs: number[]
priority: 'alta' | 'normal'
lastUpdateAt: string | null
/**
* E9 frescor — `max(lastUpdateAt, pledges.lastPledgeAt)`, resolved once on
* the server so the "há N dias" in the cell always matches the `frescor`
* ordering.
*/
lastSignalAt: string | null
expectedVotes: VoteEstimateScenarioViewModel
politicalTrendStatus: PoliticalTrendStatus | null
politicalTrendNote: string | null
pledges: MunicipalityPledgeAggregate
votePosition2022: MunicipalityVoteRankEntry | null
/** E8 "conta da cadeira" — meta × comprometido por cenário; null fora da staff view. */
goalCoverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
}
export const toMunicipalityListViewModel = (
municipality: Municipality,
pledges: MunicipalityPledgeAggregate | undefined,
votePosition2022: MunicipalityListViewModel['votePosition2022'],
goalCoverageByScenario?: Record<VoteEstimateScenario, MunicipalityGoalCoverage>,
): MunicipalityListViewModel => ({
id: municipality.id,
name: municipality.name,
slug: municipality.slug,
kind: municipality.kind,
city: municipality.city,
region: municipality.region,
ibgeCode: municipality.ibgeCode,
zoneNumber: municipality.zoneNumber ?? null,
advisorIDs: (municipality.advisors ?? []).map(relationshipId).filter((id): id is number => id !== null),
priority: municipality.priority === 'alta' ? 'alta' : 'normal',
lastUpdateAt: municipality.lastUpdateAt ?? null,
lastSignalAt: resolveMunicipalityLastSignalAt(
municipality.lastUpdateAt ?? null,
pledges?.lastPledgeAt ?? null,
),
expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes),
politicalTrendStatus: municipality.politicalTrend?.status ?? null,
politicalTrendNote: municipality.politicalTrend?.note ?? null,
pledges: pledges ?? createEmptyMunicipalityPledgeAggregate(),
votePosition2022,
goalCoverageByScenario: goalCoverageByScenario ?? createEmptyGoalCoverageByScenario(),
})
export type MunicipalityAdvisorSummary = {
id: number
name: string
phone: string | null
}
export type MunicipalityDetailViewModel = {
id: number
name: string
slug: string
kind: Municipality['kind']
city: string
region: string
ibgeCode: string
tseCityCode: string
zoneNumber: number | null
tseZones: number[]
advisorIDs: number[]
lastUpdateAt: string | null
/** Staff-only block — null for the leader view model. */
strategy: MunicipalityStrategyViewModel | null
}
/**
* The staff strategy block as the UI consumes it. Deliberately NOT derived
* from the zod input schema or payload-types: storage keeps `{ text }` rows
* and nullable scenario fields, the view flattens both.
*/
export type MunicipalityStrategyViewModel = {
priority: 'alta' | 'normal'
expectedVotes: VoteEstimateScenarioViewModel
politicalTrend: MunicipalityPoliticalTrendViewModel
strengths: string[]
risks: string[]
stateDeputyIDs: number[]
stateDeputies: StateDeputySummary[]
dobradinhaNotes: string | null
nextSteps: string | null
budgetNotes: string | null
}
export const toMunicipalityDetailViewModel = (
municipality: Municipality,
role: CampaignUser['role'],
tseZones: number[],
trendRecordedByName: string | null = null,
stateDeputies: StateDeputySummary[] = [],
): MunicipalityDetailViewModel => ({
id: municipality.id,
name: municipality.name,
slug: municipality.slug,
kind: municipality.kind,
city: municipality.city,
region: municipality.region,
ibgeCode: municipality.ibgeCode,
tseCityCode: municipality.tseCityCode,
zoneNumber: municipality.zoneNumber ?? null,
tseZones,
advisorIDs: (municipality.advisors ?? []).map(relationshipId).filter((id): id is number => id !== null),
lastUpdateAt: municipality.lastUpdateAt ?? null,
strategy:
role === 'leader'
? null
: {
priority: municipality.priority === 'alta' ? 'alta' : 'normal',
expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes),
politicalTrend: {
status: municipality.politicalTrend?.status ?? null,
note: municipality.politicalTrend?.note ?? null,
recordedByName: trendRecordedByName,
recordedAt: municipality.politicalTrend?.recordedAt ?? null,
},
strengths: (municipality.strengths ?? []).map((item) => item.text),
risks: (municipality.risks ?? []).map((item) => item.text),
stateDeputyIDs: (municipality.stateDeputies ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),
stateDeputies,
dobradinhaNotes: municipality.dobradinhaNotes ?? null,
nextSteps: municipality.nextSteps ?? null,
budgetNotes: municipality.budgetNotes ?? null,
},
})
export const loadAdvisorSummaries = async (
payload: Payload,
user: CampaignUser,
advisorIDs: number[],
): Promise<MunicipalityAdvisorSummary[]> => {
if (advisorIDs.length === 0) return []
const result = await payload.find({
collection: 'campaignUser',
where: {
and: [{ id: { in: advisorIDs } }, eligibleCampaignStaffWhere],
},
depth: 0,
pagination: false,
select: { name: true, phone: true },
user,
overrideAccess: false,
})
const advisorById = new Map(
result.docs.map(({ id, name, phone }) => [id, { id, name, phone: phone ?? null }]),
)
return advisorIDs.flatMap((id) => {
const advisor = advisorById.get(id)
return advisor ? [advisor] : []
})
}
export type EligibleAdvisorOption = Pick<CampaignUser, 'id' | 'name'> & {
isCurrent: boolean
}
export const getEligibleAdvisorOptions = async (
payload: Payload,
user: CampaignUser,
): Promise<EligibleAdvisorOption[]> => {
const result = await payload.find({
collection: 'campaignUser',
depth: 0,
pagination: false,
sort: 'name',
where: eligibleCampaignStaffWhere,
select: { name: true },
user,
overrideAccess: false,
})
return result.docs
.map(({ id, name }) => ({
id,
name,
isCurrent: id === user.id,
}))
.sort((left, right) => Number(right.isCurrent) - Number(left.isCurrent))
}