teqo-backup/src/utilities/campaignDashboardData.ts
2026-08-01 06:34:25 +00:00

265 lines
9.6 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 { isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import type { AccessibleMunicipality } from '@/lib/municipalityProximity'
import { relationshipId, requireRelationshipId } from '@/lib/relationship'
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
import type { CampaignUser, VotePledge } from '@/payload-types'
import {
loadCampaignHomeSummaryDelta,
loadStatewideStaffVoteTotalCentral,
recordCampaignVoteSummarySnapshotIfNeeded,
} from '@/utilities/campaignVoteSummarySnapshot'
import {
pickDashboardPriorityMunicipalities,
type DashboardPriorityMunicipality,
} from '@/utilities/dashboardPriorityMunicipalities'
import {
loadCampaignUserNamesByIds,
loadLeadershipContactNamesByIds,
} from '@/utilities/loadNamesByIds'
import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope'
import type { MunicipalityGoalCoverage } from '@/utilities/municipality/goalCoverage'
import {
loadMunicipalityGoalCoverageBundle,
loadStatewideSuggestedGoals,
type MunicipalityGoalCoverageBundle,
} from '@/utilities/municipality/municipalityGoalAccount'
import {
rollupMunicipalityStaffVotes,
type MunicipalityPledgeAggregate,
} from '@/utilities/votePledgeViews'
export type StaffDashboardView = {
kind: 'staff'
role: 'coordinator' | 'advisor' | 'candidate'
municipalityCount: number
withAdvisorCount: number
highPriorityCount: number
staffVoteTotalByScenario: Record<VoteEstimateScenario, number>
declaredVotesTotal: number
pledgeCount: number
missingEstimateCount: number
/** E8 "conta da cadeira" — meta × comprometido, cenário central (dashboard não tem seletor de cenário). */
goalCoverage: MunicipalityGoalCoverage
missingEstimates: Array<{
pledgeID: number
municipalityName: string
municipalitySlug: string
contactName: string
declaredVotes: number
}>
priorityMunicipalities: DashboardPriorityMunicipality[]
/**
* B14 — every município the actor can open, for the geo shortcut to match a
* browser position against without sending the position anywhere. Minimal by
* design: three short strings per row, so the whole catalog stays a few KB of
* RSC payload.
*/
accessibleMunicipalities: AccessibleMunicipality[]
recentUpdates: Array<{
id: number
municipalityName: string
municipalitySlug: string
authorName: string
createdAt: string
}>
}
type ScopedMunicipalityDoc = Awaited<
ReturnType<typeof loadMunicipalityScope>
>['municipalities'][number]
const loadStaffRollupAndGoalCoverageBundle = async (
payload: Payload,
user: CampaignUser,
municipalities: ReadonlyArray<ScopedMunicipalityDoc>,
pledgeAggregates: Map<number, MunicipalityPledgeAggregate>,
): Promise<{
rollup: ReturnType<typeof rollupMunicipalityStaffVotes>
goalCoverageBundle: MunicipalityGoalCoverageBundle
}> => {
const goalCoverageBundle = await loadMunicipalityGoalCoverageBundle(
payload,
user,
municipalities,
pledgeAggregates,
)
const rollup = rollupMunicipalityStaffVotes(municipalities, pledgeAggregates)
return { rollup, goalCoverageBundle }
}
/**
* Staff dashboard data. Leaders never reach the dashboard — they are routed to
* the supporter contact tool before this is called (see `/campanha` page).
*/
export const getCampaignDashboardData = async (
payload: Payload,
user: CampaignUser,
): Promise<StaffDashboardView> => {
// Request-scoped shared load — the map bundle on the same page reuses it.
const scope = await loadMunicipalityScope(payload, user, {})
const municipalities = { docs: scope.municipalities }
const pledgeAggregates = scope.pledgeAggregates
const municipalityIDs = municipalities.docs.map((municipality) => municipality.id)
const municipalityById = new Map(
municipalities.docs.map((municipality) => [municipality.id, municipality]),
)
// Independent of each other — only depend on municipalityIDs.
const [missingEstimatePledges, recentUpdatesResult] = await Promise.all([
municipalityIDs.length
? payload.find({
collection: 'votePledge',
where: {
and: [
{ municipality: { in: municipalityIDs } },
{ 'estimatedVotes.pessimistic': { equals: null } },
{ 'estimatedVotes.central': { equals: null } },
{ 'estimatedVotes.optimistic': { equals: null } },
],
},
depth: 0,
limit: 5,
pagination: false,
sort: '-declaredVotes',
select: { municipality: true, leadership: true, declaredVotes: true },
// Intentional admin bypass: `municipalityIDs` came from the actor's
// own access-checked scope read above; the queue needs pledge rows
// even where the actor's votePledge row access is narrower.
overrideAccess: true,
})
: Promise.resolve({ docs: [] as VotePledge[] }),
municipalityIDs.length
? payload.find({
collection: 'municipalityUpdate',
where: { municipality: { in: municipalityIDs } },
depth: 0,
limit: 3,
pagination: false,
sort: '-createdAt',
select: { municipality: true, author: true, createdAt: true },
user,
overrideAccess: false,
})
: Promise.resolve({ docs: [] }),
// Result discarded on purpose: the goal-coverage bundle below reads
// `campaignGoals` through the same `cache()`, so starting it here keeps the
// global read off the tail of the request.
loadStatewideSuggestedGoals(payload, user),
])
const { rollup, goalCoverageBundle } = await loadStaffRollupAndGoalCoverageBundle(
payload,
user,
municipalities.docs,
pledgeAggregates,
)
const { staffVoteTotalByScenario, declaredVotesTotal, pledgeCount, missingEstimateCount } = rollup
const leadershipIDs = [
...new Set(
missingEstimatePledges.docs.map((pledge) => requireRelationshipId(pledge.leadership)),
),
]
const authorIDs = [
...new Set(
recentUpdatesResult.docs
.map((update) => relationshipId(update.author))
.filter((id): id is number => id !== null),
),
]
const [contactNameByLeadershipId, authorNameById] = await Promise.all([
loadLeadershipContactNamesByIds(payload, leadershipIDs),
loadCampaignUserNamesByIds(payload, authorIDs),
])
const dashboardPriority = pickDashboardPriorityMunicipalities(
municipalities.docs,
goalCoverageBundle.coverageByMunicipalityID,
)
const accessibleMunicipalities: AccessibleMunicipality[] = municipalities.docs.map(
({ slug, name, ibgeCode }) => ({ slug, name, ibgeCode }),
)
return {
kind: 'staff',
role:
user.role === 'advisor' ? 'advisor' : user.role === 'candidate' ? 'candidate' : 'coordinator',
municipalityCount: municipalities.docs.length,
withAdvisorCount: municipalities.docs.filter(
(municipality) => (municipality.advisors ?? []).length > 0,
).length,
staffVoteTotalByScenario: { ...staffVoteTotalByScenario },
declaredVotesTotal,
pledgeCount,
missingEstimateCount,
goalCoverage: goalCoverageBundle.aggregateByScenario.central,
missingEstimates: missingEstimatePledges.docs.map((pledge) => {
const municipality = municipalityById.get(requireRelationshipId(pledge.municipality))
const contactName =
contactNameByLeadershipId.get(requireRelationshipId(pledge.leadership)) ?? 'Contato'
return {
pledgeID: pledge.id,
municipalityName: municipality?.name ?? 'Município',
municipalitySlug: municipality?.slug ?? '',
contactName,
declaredVotes: pledge.declaredVotes,
}
}),
highPriorityCount: dashboardPriority.highPriorityCount,
priorityMunicipalities: dashboardPriority.municipalities,
accessibleMunicipalities,
recentUpdates: recentUpdatesResult.docs.map((update) => {
const municipality = municipalityById.get(relationshipId(update.municipality) ?? -1)
return {
id: update.id,
municipalityName: municipality?.name ?? 'Município',
municipalitySlug: municipality?.slug ?? '',
authorName: authorNameById.get(relationshipId(update.author) ?? -1) ?? 'Usuário',
createdAt: update.createdAt,
}
}),
}
}
/** Staff Início: central staff vote total + E8 aggregate coverage (Quadro KPI contract). */
export type CampaignHomeSummaryView = Pick<StaffDashboardView, 'goalCoverage'> & {
staffVoteTotalCentral: StaffDashboardView['staffVoteTotalByScenario']['central']
/** Statewide Δ over 7 Bahia civil days; null until enough daily snapshots exist. */
homeSummaryDelta: number | null
}
/** Slim loader for `/campanha` — scope + rollup + coverage + B57 delta. */
export const loadCampaignHomeSummary = async (
payload: Payload,
user: CampaignUser,
): Promise<CampaignHomeSummaryView> => {
const scope = await loadMunicipalityScope(payload, user, {})
const { rollup, goalCoverageBundle } = await loadStaffRollupAndGoalCoverageBundle(
payload,
user,
scope.municipalities,
scope.pledgeAggregates,
)
const statewideTotal = isUnrestrictedCampaignRole(user.role)
? rollup.staffVoteTotalByScenario.central
: await loadStatewideStaffVoteTotalCentral(payload)
// B57 option A: hero stays actor-scoped; delta compares statewide live vs T−7d snapshot.
const [homeSummaryDelta] = await Promise.all([
loadCampaignHomeSummaryDelta(payload, statewideTotal),
recordCampaignVoteSummarySnapshotIfNeeded(payload, statewideTotal),
])
return {
staffVoteTotalCentral: rollup.staffVoteTotalByScenario.central,
goalCoverage: goalCoverageBundle.aggregateByScenario.central,
homeSummaryDelta,
}
}