teqo-backup/scripts/cityReportSnapshot.mjs

213 lines
6.7 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 { getMunicipalityVoteRank } from '../src/lib/municipalityVoteRank.ts'
import { campaignRoleLabels } from '../src/utilities/campaignUserProfile.ts'
import { loadMunicipalityDossierData } from '../src/utilities/municipality/municipalityDossierData.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 projectActivity = (activity) => ({
id: activity.id,
title: activity.title,
status: activity.status,
startAt: activity.startAt,
locality: activity.locality,
})
const loadSpeeches = async (payload, actor, municipalityID) => {
const result = await payload.find({
collection: 'speech',
where: buildSpeechListWhere({ page: 1, municipalities: [municipalityID] }),
depth: 0,
limit: SPEECH_LIMIT,
sort: '-speechAt',
select: {
speechAt: true,
phase: true,
summary: true,
officialTextUrl: true,
},
user: actor,
overrideAccess: false,
})
const segmentsBySpeech = await loadSegmentsForSpeeches(
payload,
actor,
result.docs.map((doc) => doc.id),
)
return {
totalCount: result.totalDocs,
rows: result.docs.map((doc) => {
const first = (segmentsBySpeech.get(doc.id) ?? [])[0]
return {
id: doc.id,
speechAt: doc.speechAt ?? null,
phase: doc.phase ?? null,
officialTextUrl: doc.officialTextUrl ?? null,
excerpt: first?.text ?? doc.summary ?? null,
}
}),
}
}
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,
})),
}
}
/**
* 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] = await Promise.all([
loadAdvisorSummaries(payload, actor, detail.advisorIDs),
loadSpeeches(payload, actor, context.id),
loadDemands(payload, actor, context.id),
])
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,
demographics: dossier.demographics,
}
}