teqo/src/utilities/supporterListOverviewAggregate.ts
Francisco Ignácio Fontoura Solla cf13c441aa Remodel /campanha around predefined Praças replacing electoral nuclei.
436 seeded Praças (municipality-based; per-TSE-zone in Salvador/Camaçari) become
the operational unit; roles renamed to coordinator/advisor/leader with advisors
scoped to administered Praças. Leadership is one record per person across many
Praças/organizations; vote pledges split leader-declared vs staff-estimated
(estimates never serialized to leaders). Adds organizations, demand workflow
with staff-only cost/receipts, action-plan results with deputy presence, manual
political trend, multi-candidate comparison and the diverging red/white/blue
map mode. Migration 20260721_020109_remodel_plazas deliberately resets the
campaign vertical in production (public site untouched); test suite ported to
the new model (unit 187 / int 306 / e2e 9 green).
2026-07-21 02:28:00 -03:00

110 lines
3.8 KiB
TypeScript

import 'server-only'
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
import type { CampaignUser } from '@/payload-types'
import { getAdvisorPlazaIds, isCampaignCoordinator } from '@/utilities/campaignAccess'
import { drizzleResultRows } from '@/utilities/drizzleBulk'
import { toAggregateSqlConditions } from '@/utilities/supporterListSqlFilters'
import type { SupporterListState } from '@/utilities/supporterUi'
import type { SupporterListOverviewViewModel } from '@/utilities/supporterViewModels'
type AccessConstraint =
| { kind: 'all' }
| { kind: 'none' }
| { kind: 'plazaSet'; ids: number[] }
type PostgresDb = {
execute: (query: ReturnType<typeof sql>) => Promise<unknown>
}
const resolveAccessConstraint = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
advisorPlazaIds?: number[],
): Promise<AccessConstraint> => {
if (isCampaignCoordinator(user)) return { kind: 'all' }
if (user.role !== 'advisor') return { kind: 'none' }
const ids = advisorPlazaIds ?? (await getAdvisorPlazaIds(payload, user.id))
return { kind: 'plazaSet', ids }
}
const buildAggregateSql = (
state: SupporterListState,
access: AccessConstraint,
): ReturnType<typeof sql> => {
const { conditions: filterConditions, needsContactJoin } = toAggregateSqlConditions(state)
const conditions = [...filterConditions]
if (access.kind === 'plazaSet') {
if (access.ids.length === 0) {
// No accessible plazas → no rows. Keep a trivially-false condition.
conditions.push(sql`FALSE`)
} else {
conditions.push(
sql`"supporter"."plaza_id" IN (${sql.join(
access.ids.map((id) => sql`${id}`),
sql`, `,
)})`,
)
}
}
const whereClause =
conditions.length > 0 ? sql` WHERE ${sql.join(conditions, sql` AND `)}` : sql``
const fromClause = needsContactJoin
? sql`FROM "supporter" LEFT JOIN "contact" ON "contact"."id" = "supporter"."contact_id"`
: sql`FROM "supporter"`
// `total` is NOT selected here — the caller already computed it from the
// list query's `totalDocs` (same filters, same access scope), so the
// aggregate only needs to run the two FILTER counts.
return sql`
SELECT
COUNT(*) FILTER (WHERE "supporter"."vote_intention" IN ('certo', 'tende_a_certo')) AS "certo_and_tende",
COUNT(*) FILTER (WHERE "supporter"."vote_intention" = 'indeciso') AS "indeciso"
${fromClause}${whereClause}
`
}
/**
* Computes the supporter overview KPIs (vote-intention breakdown) for the
* caller's already-resolved list `total` (from `result.totalDocs`, computed
* with the same filters and access scope). Skips the aggregate query entirely
* when `total` is 0, since the panel is hidden in that case anyway.
*/
export const computeSupporterListOverviewAggregate = async (
payload: Payload,
user: CampaignUser,
state: SupporterListState,
total: number,
advisorPlazaIds?: number[],
): Promise<SupporterListOverviewViewModel | null> => {
if (!Number.isSafeInteger(total) || total <= 0) return null
const access = await resolveAccessConstraint(payload, user, advisorPlazaIds)
if (access.kind === 'none') return null
if (access.kind === 'plazaSet' && access.ids.length === 0) return null
if (payload.db.name !== 'postgres') {
throw new Error('O overview de apoiadores exige o adaptador PostgreSQL.')
}
const database = payload.db as unknown as { drizzle?: PostgresDb }
const drizzle = database.drizzle
if (!drizzle || typeof drizzle.execute !== 'function') {
throw new Error('A sessão PostgreSQL do overview de apoiadores não está disponível.')
}
const result = await drizzle.execute(buildAggregateSql(state, access))
const row = drizzleResultRows(result)[0]
if (!row) return null
return {
total,
certoAndTende: Number(row.certo_and_tende ?? 0),
indeciso: Number(row.indeciso ?? 0),
}
}