perf(campanha): roda as funções em gru1 e encurta o waterfall de queries

As funções rodavam no default iad1 (Virgínia) enquanto o Neon fica em
sa-east-1 (São Paulo): cada query do Payload atravessava o equador duas
vezes (~120 ms por ida-e-volta) e uma página do /campanha faz uma dúzia.
Medido em produção: 410-470 ms de TTFB para um 307 que não toca o banco.

- vercel.json fixa regions: [gru1], mesma região AWS do banco (Hobby
  permite uma região, mas permite escolher qual).
- loadStatewideSuggestedGoals passa a ser exportada e é disparada dentro
  do Promise.all que já lê o escopo, na lista e no dashboard: o read do
  global campaignGoals saía sozinho no fim da requisição.
- loadMunicipalityScope lê os pledges em paralelo quando o ator é
  irrestrito e não há filtro (aí o IN de 435 ids não exclui nada);
  qualquer escopo mais estreito continua esperando os ids.

Débitos medidos no caminho e registrados em TECH-DEBT: o fan-out de
leituras do ator em getFreshCampaignUser (~5 por render) e a leitura
dupla em authenticateCampaignToken.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 21:00:06 -03:00
parent 8b8a242073
commit 162d4c10fe
10 changed files with 215 additions and 51 deletions

View file

@ -10,7 +10,7 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si
- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool.
- **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below.
- Hosting stays on Vercel for now. No self-host/Coolify migration in progress.
- Hosting stays on Vercel for now. No self-host/Coolify migration in progress. **The functions run in `gru1` (São Paulo), pinned in `vercel.json` — do not drop it back to the `iad1` default.** The Neon database is in `sa-east-1`, which is the same AWS region as `gru1`: with the default `iad1` every Payload query crossed the equator twice (~120 ms per round trip, and a page issues a dozen), which is what made `/campanha` feel broken until 2026-07-26. Hobby allows a single region but lets you choose which one. Verify after any deploy that changes function config: `curl -sI https://pt.jorgesolla.com.br/campanha` must answer `x-vercel-id: gru1::gru1::…`, never `gru1::iad1::…`.
- Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL.
- **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto.

View file

@ -100,6 +100,7 @@ Dois contexts principais + um de suporte, espelhados nos prefixos de módulo e n
| 2026-07-25 | **Pass 2 W1/D5:** listas de tabela no sistema compartilhado (colunas como dado); exceções documentadas: atividades (cards), `TerritoryOverviewTable` (sort client, ≤27 linhas), comparação de candidatos, LeaderContacts, preview de import |
| 2026-07-25 | **Pass 2 W2:** dedup por POLÍTICA continua valendo (wrappers nomeados como `runCampaignFormAction`), nunca plumbing genérico de dados; aliases de access `canUpdateX = canReadX` são declarações de política deliberadas |
| 2026-07-25 | **Pass 2 W4b:** knip `exports`/`types`/`enumMembers` em ERROR no CI; tests fazem parte do grafo; kit `ui/` sem peças mortas (shadcn re-adiciona sob demanda) |
| 2026-07-26 | Funções rodam em `gru1` (São Paulo), fixado em `vercel.json`, na mesma região AWS do Neon (`sa-east-1`) — o default `iad1` fazia cada query do Payload atravessar o equador (~120 ms por ida-e-volta, ~12 por página). Compute perto do dado vence compute perto do usuário aqui, porque o eleitorado é todo baiano |
| 2026-07-25 | **C13:** vocabulário do produto é lei no código — "Plano de Ação" → **Atividade** (`activity`), rename completo (entidade, banco, rota, copy) por migration data-preserving escrita à mão; termo velho banido pelo guard de convenções em `src`+`tests`+`scripts` |
## Manutenção deste documento

View file

@ -4,24 +4,26 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
## Debt Ledger
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b |
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | open | AGENTS Known Gaps #1 |
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | |
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | |
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 |
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b |
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | open | AGENTS Known Gaps #1 |
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | |
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | |
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 |
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | low since the `gru1` move (~2 ms each; it was ~120 ms each while functions ran in `iad1`) | M | P2 | open — measured 2026-07-26. A React `cache()` layer would dedupe across requests, but it changes actor freshness inside server actions that mutate roles (`reloadUnrestrictedActor`), so it needs its own delivery with pins | |
| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ |
## Smell Inventory

View file

@ -11,7 +11,10 @@ import {
pickDashboardPriorityMunicipalities,
} from '@/utilities/dashboardPriorityMunicipalities'
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipalityGoalAccount'
import {
loadMunicipalityGoalCoverageBundle,
loadStatewideSuggestedGoals,
} from '@/utilities/municipalityGoalAccount'
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeViews'
@ -121,6 +124,10 @@ export const getCampaignDashboardData = async (
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 = rollupMunicipalityStaffVotes(municipalities.docs, pledgeAggregates)

View file

@ -4,8 +4,12 @@ import { cache } from 'react'
import type { Payload, Where } from 'payload'
import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import type { CampaignUser, Municipality } from '@/payload-types'
import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData'
import {
aggregateAllPledgesByMunicipality,
aggregatePledgesByMunicipality,
} from '@/utilities/votePledgeData'
import { type MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
/** Superset of the fields the dashboard, map, and list overview consume. */
@ -32,27 +36,42 @@ const loadScope = async (
user: CampaignUser,
where: Where,
): Promise<MunicipalityScope> => {
const result = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
select: {
name: true,
slug: true,
kind: true,
region: true,
ibgeCode: true,
advisors: true,
priority: true,
expectedVotes: true,
},
where,
user,
overrideAccess: false,
})
const municipalitiesPromise = payload
.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
select: {
name: true,
slug: true,
kind: true,
region: true,
ibgeCode: true,
advisors: true,
priority: true,
expectedVotes: true,
},
where,
user,
overrideAccess: false,
})
.then((result) => result.docs as ScopedMunicipalityDoc[])
const municipalities = result.docs as ScopedMunicipalityDoc[]
// An unrestricted actor with no filter has the WHOLE catalog in scope, so the
// pledge aggregate has nothing to narrow: both reads can leave in the same
// round trip. Every narrower scope still has to wait for the município ids —
// the aggregate would otherwise count pledges the actor cannot read.
if (isUnrestrictedCampaignRole(user.role) && Object.keys(where).length === 0) {
const [municipalities, pledgeAggregates] = await Promise.all([
municipalitiesPromise,
aggregateAllPledgesByMunicipality(payload),
])
return { municipalities, pledgeAggregates }
}
const municipalities = await municipalitiesPromise
const pledgeAggregates = await aggregatePledgesByMunicipality(
payload,
municipalities.map((municipality) => municipality.id),

View file

@ -45,11 +45,18 @@ type GoalAccountReader = CampaignUser | User
* over the whole catalog, never a filtered scope, and is
* `cache()`-deduplicated so dashboard + list + detail don't each recompute it
* on requests that touch more than one.
*
* Exported so a page can start it inside the `Promise.all` that already reads
* the scope: the coverage bundle needs it, and awaiting it only at the bundle
* call site left one global read (plus the catalog-wide pure computation) alone
* at the tail of the request.
*/
const loadSuggestedGoalsCached = cache(async (payload: Payload, user: GoalAccountReader) => {
const goals = await loadCampaignGoals(payload, user)
return computeStatewideSuggestedGoals(goals)
})
export const loadStatewideSuggestedGoals = cache(
async (payload: Payload, user: GoalAccountReader) => {
const goals = await loadCampaignGoals(payload, user)
return computeStatewideSuggestedGoals(goals)
},
)
export type MunicipalityGoalCoverageBundle = {
coverageByMunicipalityID: Map<number, Record<VoteEstimateScenario, MunicipalityGoalCoverage>>
@ -74,7 +81,7 @@ export const loadMunicipalityGoalCoverageBundle = async (
}>,
pledgeAggregates: Map<number, MunicipalityPledgeAggregate>,
): Promise<MunicipalityGoalCoverageBundle> => {
const { suggestedGoalBySlug } = await loadSuggestedGoalsCached(payload, user)
const { suggestedGoalBySlug } = await loadStatewideSuggestedGoals(payload, user)
const coverageByMunicipalityID = new Map<
number,
@ -130,7 +137,7 @@ export const loadMunicipalityGoalAccount = async (
municipality: { slug: string; expectedVotes?: VoteEstimateScenarioFields | null },
pledgeAggregate: MunicipalityPledgeAggregate,
): Promise<MunicipalityGoalAccount> => {
const { suggestedGoalBySlug, potentialBySlug } = await loadSuggestedGoalsCached(payload, user)
const { suggestedGoalBySlug, potentialBySlug } = await loadStatewideSuggestedGoals(payload, user)
const suggestedGoalByScenario =
suggestedGoalBySlug.get(municipality.slug) ?? emptySuggestedGoalByScenario
const goalCoverage = computeGoalCoverage(

View file

@ -15,7 +15,10 @@ import type { CampaignUser, Municipality } from '@/payload-types'
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
import { centralDeficitSortValue, type MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipalityGoalAccount'
import {
loadMunicipalityGoalCoverageBundle,
loadStatewideSuggestedGoals,
} from '@/utilities/municipalityGoalAccount'
import {
buildMunicipalityListWhere,
municipalityPageSize,
@ -361,6 +364,10 @@ export const loadMunicipalityListPageBundle = async (
? { where, rows: staffScopePromise.then((scope) => scope?.municipalities ?? []) }
: null,
),
// Result discarded on purpose: the `campaignGoals` read below is
// `cache()`-deduplicated, so starting it here moves it off the tail of the
// request instead of adding a round trip after everything else resolved.
isStaff ? loadStatewideSuggestedGoals(payload, user) : null,
])
let overview: MunicipalityListOverviewData | null = null

View file

@ -1,6 +1,6 @@
import 'server-only'
import type { Payload, PayloadRequest } from 'payload'
import type { Payload, PayloadRequest, Where } from 'payload'
import {
effectivePledgeVotesForScenario,
@ -30,13 +30,35 @@ export const aggregatePledgesByMunicipality = async (
payload: Pick<Payload, 'find'>,
municipalityIDs: number[],
req?: PayloadRequest,
): Promise<Map<number, MunicipalityPledgeAggregate>> => {
if (municipalityIDs.length === 0) return new Map()
return aggregatePledgesWhere(payload, { municipality: { in: municipalityIDs } }, req)
}
/**
* The same aggregate over EVERY pledge, for a caller whose município scope is
* the whole catalog: there `municipality IN (…)` excludes nothing, so dropping
* it lets the pledge read leave in the same round trip as the município read
* instead of waiting for its ids (and drops a 435-element IN list). Keys the
* caller doesn't know are harmless — every consumer looks the map up by
* município id, none iterates it.
*/
export const aggregateAllPledgesByMunicipality = (
payload: Pick<Payload, 'find'>,
req?: PayloadRequest,
): Promise<Map<number, MunicipalityPledgeAggregate>> => aggregatePledgesWhere(payload, {}, req)
const aggregatePledgesWhere = async (
payload: Pick<Payload, 'find'>,
where: Where,
req?: PayloadRequest,
): Promise<Map<number, MunicipalityPledgeAggregate>> => {
const aggregates = new Map<number, MunicipalityPledgeAggregate>()
if (municipalityIDs.length === 0) return aggregates
const result = await payload.find({
collection: 'votePledge',
where: { municipality: { in: municipalityIDs } },
where,
depth: 0,
limit: 0,
pagination: false,

View file

@ -0,0 +1,95 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
/**
* `loadMunicipalityScope` reads the pledge aggregate two different ways: an
* unrestricted actor with no filter holds the whole catalog, so both reads
* leave in one round trip with no `municipality IN (…)` narrowing; every
* narrower scope waits for the município ids. These pin that the two paths
* agree, and that the unfiltered shortcut stays out of reach of an actor whose
* scope is NOT the whole catalog.
*/
describe('loadMunicipalityScope', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('aggregates the same pledge whether the scope is unfiltered or filtered', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
supportStatus: 'engajado',
})
await fixtures.createVotePledge({
leadership: leadership.id,
municipality: municipality.id,
declaredVotes: 80,
estimatedVotes: { pessimistic: null, central: 120, optimistic: null },
})
// Whole catalog: the unfiltered path, where the pledge read carries no id list.
const unfiltered = await loadMunicipalityScope(payload, coordinator, {})
// Same actor, narrowed by slug: the path that waits for the município ids.
const filtered = await loadMunicipalityScope(payload, coordinator, {
slug: { equals: municipality.slug },
})
expect(filtered.municipalities.map(({ id }) => id)).toEqual([municipality.id])
expect(unfiltered.pledgeAggregates.get(municipality.id)).toEqual(
filtered.pledgeAggregates.get(municipality.id),
)
expect(unfiltered.pledgeAggregates.get(municipality.id)).toMatchObject({
declaredTotal: 80,
pledgeCount: 1,
missingEstimateCount: 0,
})
expect(unfiltered.pledgeAggregates.get(municipality.id)?.effectiveByScenario.central).toBe(120)
})
it('never hands an advisor pledges outside the municipalities they administer', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getMunicipality()
const foreign = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered, [advisor.id])
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact: contact.id,
municipalities: [foreign.id],
supportStatus: 'engajado',
})
await fixtures.createVotePledge({
leadership: leadership.id,
municipality: foreign.id,
declaredVotes: 900,
})
// Empty `where`, but the advisor's scope is his portfolio — the whole-catalog
// shortcut must not apply, or this aggregate would carry a foreign pledge.
const scope = await loadMunicipalityScope(payload, advisor, {})
expect(scope.municipalities.map(({ id }) => id)).toEqual([administered.id])
expect(scope.pledgeAggregates.has(foreign.id)).toBe(false)
})
})

4
vercel.json Normal file
View file

@ -0,0 +1,4 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"regions": ["gru1"]
}