mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
309 lines
10 KiB
JavaScript
309 lines
10 KiB
JavaScript
/**
|
|
* Snapshot composer for the city report (C163).
|
|
*
|
|
* Runs on the homeserver against production, read-only: composes the same
|
|
* loaders the `/campanha` detail/dossier use (no parallel source of truth),
|
|
* projects out PII and fields the report does not need, and returns plain
|
|
* JSON. The render step never touches the database again.
|
|
*
|
|
* Integration layer, not a pure lib: it imports Payload-coupled utilities and
|
|
* is exercised by `tests/int/cityReportSnapshot.int.spec.ts`.
|
|
*/
|
|
|
|
import { FEDERAL_DEPUTY_OFFICE, HISTORICAL_SERIES_YEARS } from '../src/lib/electionResults.ts'
|
|
import { getMunicipalityCatalogEntry } from '../src/lib/municipalityCatalog.ts'
|
|
import { getMunicipalityVoteRank } from '../src/lib/municipalityVoteRank.ts'
|
|
import { normalizeForSearch } from '../src/lib/speechSearch.ts'
|
|
import { excerptOffsetSeconds } from '../src/lib/speechVod.ts'
|
|
import { campaignRoleLabels } from '../src/utilities/campaignUserProfile.ts'
|
|
import { loadMunicipalityDossierData } from '../src/utilities/municipality/municipalityDossierData.ts'
|
|
import {
|
|
municipalityElectionGeography,
|
|
municipalityGeographyWhere,
|
|
} from '../src/utilities/municipality/municipalityElectionGeography.ts'
|
|
import {
|
|
getMunicipalityDetailViewModel,
|
|
resolveAccessibleMunicipalityContext,
|
|
} from '../src/utilities/municipality/municipalityPageData.ts'
|
|
import { loadAdvisorSummaries } from '../src/utilities/municipality/municipalityViewModels.ts'
|
|
import { buildSpeechListWhere } from '../src/utilities/speech/speechListFilters.ts'
|
|
import { loadSegmentsForSpeeches } from '../src/utilities/speech/speechPageData.ts'
|
|
|
|
const SPEECH_LIMIT = 10
|
|
const DEMAND_LIMIT = 20
|
|
const COMPETITOR_LIMIT = 5
|
|
/** Data value of the office enum (only the federal constant is exported today). */
|
|
const STATE_DEPUTY_OFFICE = 'deputado_estadual'
|
|
const REFERENCE_YEAR = 2022
|
|
|
|
const projectActivity = (activity) => ({
|
|
id: activity.id,
|
|
title: activity.title,
|
|
status: activity.status,
|
|
startAt: activity.startAt,
|
|
locality: activity.locality,
|
|
})
|
|
|
|
/**
|
|
* Speeches mentioning the município, projected for the report: the official
|
|
* summary says what the speech is; `mentionExcerpt` is the passage that
|
|
* literally names the city (null when the acervo tag has no literal mention in
|
|
* the ASR segments — e.g. a gazetteer tagging among dozens of municípios);
|
|
* `mentionedMunicipalityCount` gives that context.
|
|
*/
|
|
const loadSpeeches = async (payload, actor, municipalityID, municipalityName) => {
|
|
const result = await payload.find({
|
|
collection: 'speech',
|
|
where: buildSpeechListWhere({ page: 1, municipalities: [municipalityID] }),
|
|
depth: 0,
|
|
limit: SPEECH_LIMIT,
|
|
sort: '-speechAt',
|
|
select: {
|
|
speechAt: true,
|
|
type: true,
|
|
phase: true,
|
|
summary: true,
|
|
officialTextUrl: true,
|
|
youtubeUrl: true,
|
|
vodPlaybackUrl: true,
|
|
excerptTMs: true,
|
|
eventStartAt: true,
|
|
mentionedMunicipalities: true,
|
|
},
|
|
user: actor,
|
|
overrideAccess: false,
|
|
})
|
|
const segmentsBySpeech = await loadSegmentsForSpeeches(
|
|
payload,
|
|
actor,
|
|
result.docs.map((doc) => doc.id),
|
|
)
|
|
const normalizedCity = municipalityName ? normalizeForSearch(municipalityName) : ''
|
|
return {
|
|
totalCount: result.totalDocs,
|
|
rows: result.docs.map((doc) => {
|
|
const segments = segmentsBySpeech.get(doc.id) ?? []
|
|
const mentionSegment = normalizedCity
|
|
? segments.find((segment) => normalizeForSearch(segment.text).includes(normalizedCity))
|
|
: undefined
|
|
return {
|
|
id: doc.id,
|
|
speechAt: doc.speechAt ?? null,
|
|
type: doc.type ?? null,
|
|
phase: doc.phase ?? null,
|
|
summary: doc.summary ?? null,
|
|
officialTextUrl: doc.officialTextUrl ?? null,
|
|
youtubeUrl: doc.youtubeUrl ?? null,
|
|
vodPlaybackUrl: doc.vodPlaybackUrl ?? null,
|
|
/** Offset of the excerpt inside the session video (YouTube start). */
|
|
youtubeExcerptStartSeconds: excerptOffsetSeconds(doc.excerptTMs, doc.eventStartAt),
|
|
mentionExcerpt: mentionSegment?.text ?? null,
|
|
mentionedMunicipalityCount: Array.isArray(doc.mentionedMunicipalities)
|
|
? doc.mentionedMunicipalities.length
|
|
: 0,
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
const loadDemands = async (payload, actor, municipalityID) => {
|
|
const result = await payload.find({
|
|
collection: 'campaignDemand',
|
|
where: { municipality: { equals: municipalityID } },
|
|
depth: 0,
|
|
limit: DEMAND_LIMIT,
|
|
sort: '-updatedAt',
|
|
select: { title: true, kind: true, status: true, updatedAt: true },
|
|
user: actor,
|
|
overrideAccess: false,
|
|
})
|
|
return {
|
|
totalCount: result.totalDocs,
|
|
rows: result.docs.map((doc) => ({
|
|
id: doc.id,
|
|
title: doc.title,
|
|
kind: doc.kind,
|
|
status: doc.status,
|
|
updatedAt: doc.updatedAt,
|
|
})),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Top federal/state deputy candidates by votes inside the município (reference
|
|
* year), with the 2014/2018/2022 series — the report's "main competitors"
|
|
* table. Same TSE collection the app's comparison card reads.
|
|
*/
|
|
const loadCompetitors = async (payload, actor, slug, office) => {
|
|
const entry = getMunicipalityCatalogEntry(slug)
|
|
if (!entry) return { office, rows: [] }
|
|
const result = await payload.find({
|
|
collection: 'electionCandidateVote',
|
|
where: {
|
|
and: [
|
|
{ year: { in: [...HISTORICAL_SERIES_YEARS] } },
|
|
{ office: { equals: office } },
|
|
{ turn: { equals: '1' } },
|
|
{ voteType: { equals: 'nominal' } },
|
|
municipalityGeographyWhere(municipalityElectionGeography(entry)),
|
|
],
|
|
},
|
|
depth: 0,
|
|
limit: 0,
|
|
pagination: false,
|
|
select: { year: true, candidateNumber: true, candidateName: true, party: true, votes: true },
|
|
user: actor,
|
|
overrideAccess: false,
|
|
})
|
|
|
|
const byCandidate = new Map()
|
|
for (const row of result.docs) {
|
|
const current = byCandidate.get(row.candidateNumber) ?? {
|
|
candidateNumber: row.candidateNumber,
|
|
name: row.candidateName,
|
|
party: row.party ?? null,
|
|
votesByYear: {},
|
|
}
|
|
current.votesByYear[String(row.year)] =
|
|
(current.votesByYear[String(row.year)] ?? 0) + (row.votes ?? 0)
|
|
byCandidate.set(row.candidateNumber, current)
|
|
}
|
|
|
|
const rows = [...byCandidate.values()]
|
|
.sort(
|
|
(left, right) =>
|
|
(right.votesByYear[String(REFERENCE_YEAR)] ?? 0) -
|
|
(left.votesByYear[String(REFERENCE_YEAR)] ?? 0) ||
|
|
left.candidateNumber - right.candidateNumber,
|
|
)
|
|
.slice(0, COMPETITOR_LIMIT)
|
|
return { office, year: REFERENCE_YEAR, rows }
|
|
}
|
|
|
|
/**
|
|
* Composes the snapshot. `actor` must be a real `campaignUser` doc (the CLI
|
|
* resolves and fails closed); loaders run with `overrideAccess: false` so the
|
|
* snapshot is exactly what that role may read.
|
|
*
|
|
* @param {object} params
|
|
* @param {import('payload').Payload} params.payload
|
|
* @param {import('../src/payload-types.ts').CampaignUser} params.actor
|
|
* @param {string} params.slug
|
|
* @param {string} [params.readAt]
|
|
* @param {string | null} [params.codeSha]
|
|
* @param {string | null} [params.database]
|
|
*/
|
|
export const composeCityReportSnapshot = async ({
|
|
payload,
|
|
actor,
|
|
slug,
|
|
readAt = new Date().toISOString(),
|
|
codeSha = null,
|
|
database = null,
|
|
}) => {
|
|
const context = await resolveAccessibleMunicipalityContext(payload, actor, slug)
|
|
const detail = await getMunicipalityDetailViewModel(payload, context, actor)
|
|
const dossier = await loadMunicipalityDossierData(payload, actor, detail)
|
|
const [advisors, speeches, demands, federalCompetitors, stateCompetitors] = await Promise.all([
|
|
loadAdvisorSummaries(payload, actor, detail.advisorIDs),
|
|
loadSpeeches(payload, actor, context.id, detail.city),
|
|
loadDemands(payload, actor, context.id),
|
|
loadCompetitors(payload, actor, slug, FEDERAL_DEPUTY_OFFICE),
|
|
loadCompetitors(payload, actor, slug, STATE_DEPUTY_OFFICE),
|
|
])
|
|
|
|
const strategy = detail.strategy
|
|
const rank2022 = getMunicipalityVoteRank(slug)
|
|
|
|
return {
|
|
meta: {
|
|
source: 'Base Teqo (produção, leitura read-only)',
|
|
readAt,
|
|
readOnly: true,
|
|
database,
|
|
codeSha,
|
|
actorId: actor.id,
|
|
actorName: actor.name ?? null,
|
|
actorRole: actor.role,
|
|
actorRoleLabel: campaignRoleLabels[actor.role] ?? actor.role,
|
|
},
|
|
municipality: {
|
|
id: context.id,
|
|
slug: context.slug,
|
|
name: detail.name,
|
|
kind: detail.kind,
|
|
region: detail.region,
|
|
ibgeCode: detail.ibgeCode,
|
|
},
|
|
electoral: {
|
|
series: dossier.baseline?.series ?? [],
|
|
rank2022,
|
|
},
|
|
goal: dossier.goalAccount
|
|
? {
|
|
suggestedGoal: dossier.goalAccount.suggestedGoal,
|
|
goalCoverage: dossier.goalAccount.goalCoverage,
|
|
territorialClass: { class: dossier.goalAccount.territorialClass.class },
|
|
}
|
|
: null,
|
|
pledges: {
|
|
declaredTotal: dossier.pledgeAggregate.declaredTotal,
|
|
lastPledgeAt: dossier.pledgeAggregate.lastPledgeAt,
|
|
},
|
|
leaderships: {
|
|
totalCount: dossier.leaderships.totalCount,
|
|
rows: dossier.leaderships.rows.map((row) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
supportStatus: row.supportStatus,
|
|
organizationNames: row.organizationNames,
|
|
hasAppAccess: row.hasAppAccess,
|
|
updatedAt: row.updatedAt,
|
|
})),
|
|
},
|
|
advisors: advisors.map((advisor) => ({ id: advisor.id, name: advisor.name })),
|
|
signals: {
|
|
totalCount: dossier.signals.totalCount,
|
|
rows: dossier.signals.rows.map((row) => ({
|
|
id: row.id,
|
|
createdAt: row.createdAt,
|
|
body: row.body,
|
|
polarity: row.polarity,
|
|
urgent: row.urgent,
|
|
adversarySignal: row.adversarySignal,
|
|
responsibleName: row.responsibleName,
|
|
})),
|
|
},
|
|
activities: {
|
|
upcoming: dossier.upcomingActivities.map(projectActivity),
|
|
recent: dossier.recentActivities.map(projectActivity),
|
|
},
|
|
conjuncture: strategy
|
|
? {
|
|
priority: strategy.priority,
|
|
expectedVotes: strategy.expectedVotes,
|
|
politicalTrend: strategy.politicalTrend,
|
|
engagementLevel: strategy.engagementLevel,
|
|
levelNote: strategy.levelNote,
|
|
strengths: strategy.strengths,
|
|
risks: strategy.risks,
|
|
dobradinhaNotes: strategy.dobradinhaNotes,
|
|
nextSteps: strategy.nextSteps,
|
|
budgetNotes: strategy.budgetNotes,
|
|
stateDeputies: strategy.stateDeputies.map((deputy) => ({
|
|
id: deputy.id,
|
|
name: deputy.name,
|
|
party: deputy.party,
|
|
})),
|
|
}
|
|
: null,
|
|
speeches,
|
|
demands,
|
|
competitors: {
|
|
referenceYear: REFERENCE_YEAR,
|
|
federal: federalCompetitors.rows,
|
|
state: stateCompetitors.rows,
|
|
},
|
|
demographics: dossier.demographics,
|
|
}
|
|
}
|