diff --git a/AGENTS.md b/AGENTS.md index 4f2dc1c9..439f8533 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c924a862..6bf23268 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md index 387cf643..865d6b47 100644 --- a/docs/TECH-DEBT.md +++ b/docs/TECH-DEBT.md @@ -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 diff --git a/src/utilities/campaignDashboardData.ts b/src/utilities/campaignDashboardData.ts index fb2a1aac..67f162b8 100644 --- a/src/utilities/campaignDashboardData.ts +++ b/src/utilities/campaignDashboardData.ts @@ -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) diff --git a/src/utilities/campaignMunicipalityScope.ts b/src/utilities/campaignMunicipalityScope.ts index 9a8155d1..0f278d1f 100644 --- a/src/utilities/campaignMunicipalityScope.ts +++ b/src/utilities/campaignMunicipalityScope.ts @@ -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 => { - 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), diff --git a/src/utilities/municipalityGoalAccount.ts b/src/utilities/municipalityGoalAccount.ts index 96a9a6ac..2fb0558a 100644 --- a/src/utilities/municipalityGoalAccount.ts +++ b/src/utilities/municipalityGoalAccount.ts @@ -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> @@ -74,7 +81,7 @@ export const loadMunicipalityGoalCoverageBundle = async ( }>, pledgeAggregates: Map, ): Promise => { - 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 => { - const { suggestedGoalBySlug, potentialBySlug } = await loadSuggestedGoalsCached(payload, user) + const { suggestedGoalBySlug, potentialBySlug } = await loadStatewideSuggestedGoals(payload, user) const suggestedGoalByScenario = suggestedGoalBySlug.get(municipality.slug) ?? emptySuggestedGoalByScenario const goalCoverage = computeGoalCoverage( diff --git a/src/utilities/municipalityPageData.ts b/src/utilities/municipalityPageData.ts index 5c4e3194..3bb867ba 100644 --- a/src/utilities/municipalityPageData.ts +++ b/src/utilities/municipalityPageData.ts @@ -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 diff --git a/src/utilities/votePledgeData.ts b/src/utilities/votePledgeData.ts index 30f9e417..e635ac6c 100644 --- a/src/utilities/votePledgeData.ts +++ b/src/utilities/votePledgeData.ts @@ -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, municipalityIDs: number[], req?: PayloadRequest, +): Promise> => { + 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, + req?: PayloadRequest, +): Promise> => aggregatePledgesWhere(payload, {}, req) + +const aggregatePledgesWhere = async ( + payload: Pick, + where: Where, + req?: PayloadRequest, ): Promise> => { const aggregates = new Map() - if (municipalityIDs.length === 0) return aggregates const result = await payload.find({ collection: 'votePledge', - where: { municipality: { in: municipalityIDs } }, + where, depth: 0, limit: 0, pagination: false, diff --git a/tests/int/campaignMunicipalityScope.int.spec.ts b/tests/int/campaignMunicipalityScope.int.spec.ts new file mode 100644 index 00000000..b7e8cd05 --- /dev/null +++ b/tests/int/campaignMunicipalityScope.int.spec.ts @@ -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) + }) +}) diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..78436c5f --- /dev/null +++ b/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "regions": ["gru1"] +}