54 KiB
Payload CMS Development Rules
You are an expert Payload CMS developer. When working with Payload projects, follow these rules:
Teqo Project Context (read this first)
Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public site + editorial CMS + an internal /campanha campaign-management area, all in this one Next.js + Payload app. Full architecture/decision doc lives outside this repo at plano-arquitetura-campanha-2026.md (Cowork workspace) — check it for the "why" behind decisions below.
Product constraints (reopen only with new product evidence):
These are product/architecture constraints — not a ban on editing existing code. Engineering choices from past deliveries, plans, and project notebooks are history, not locks: prefer changing the module that already owns a concern over adding a twin beside it. What stays frozen for safety: shipped migration files (never edit retroactively), Consent/LGPD fail-closed behavior, and public URL contracts unless the delivery explicitly owns that change. See .agents/rules/engineering-standards.mdc ("Edit the owner, don't twin").
-
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 arecoordinator(label "Coordenador Geral"),advisor("Assessor"),leader("Liderança"), andcandidate("Candidato", unrestricted visibility), all on the separate auth collectioncampaignUser(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 (contactUNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality withdeclaredVotes(staff-entered, leader-visible) vsestimatedVotes(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(supersedesremodelagem-pracas.md). Details: "Campaign Municípios model" below. -
Hosting stays on Vercel for now. No self-host/Coolify migration in progress. The functions run in
gru1(São Paulo), pinned invercel.json— do not drop it back to theiad1default. The Neon database is insa-east-1, which is the same AWS region asgru1: with the defaultiad1every Payload query crossed the equator twice (~120 ms per round trip, and a page issues a dozen), which is what made/campanhafeel 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/campanhamust answerx-vercel-id: gru1::gru1::…, nevergru1::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.
Real conventions already established in this codebase (follow these patterns; extend or edit the owner when the concern already exists — do not invent a parallel path):
Contactis the normalized "person" record (name, email, phone, state/city viasrc/lib/cities.ts, CEP). Don't create a parallel "person" collection for new features (e.g. líderanças/apoiadores) — add a join collection that relates toContact, the same waySignatureandSubscriptiondo.- Every action that writes to more than one collection (see
src/app/(frontend)/actions/*.ts) wraps the writes in a Payload transaction (payload.db.beginTransaction/commitTransaction/rollbackTransaction) and passesreq: { transactionID }to everypayload.create/payload.updatecall. Follow this pattern for any new multi-collection write. - LGPD consent is tracked via a
Consentcollection (richText, versioned, optional stablekey) referenced by relationship fromSignature/Subscription/campaignleadership. New campaign opt-in flows resolve Consent by stablekey(e.g.lideranca-autopreenchimento) and fail closed when missing — don't invent a parallel consent mechanism or hardcode document IDs for new work. - Cache invalidation: collections/globals call
revalidateDocumentById(src/utilities/documents.ts) /revalidateGlobal(src/utilities/globals.ts) in anafterChangehook. Add the same hook to any new collection/global that backs a public page. Collections whose listing is cached under a shared tag also call the listing helper (revalidateCollectionListing, e.g.revalidatePostsListing()→revalidateTag('posts')) — see "Posts & Tags" below. - Admin UI is organized via
admin.group(e.g.'Abaixo-assinados','Contatos','Configurações','Paginas','Campanha','Dados Eleitorais') — group new collections consistently instead of leaving them ungrouped. - i18n: Payload admin defaults to
pt(notpt-BR— seepayload.config.ts,payload/i18n/pt). - Language & naming conventions: ALL code identifiers are in English — variable, function, parameter, type/interface names, local constants, and Next.js dynamic route segment folder names used as param keys (e.g.
[type]/[category]/[slug], not[tipo]/[categoria]). Portuguese is allowed ONLY in: user-visible string literals (JSX text, button/labels), imagealttext, SEO/metadata values (title/description/keywords), Payload admin config text (labels/singular/plural/admin.description/fieldlabel), and intentional URL slug/enum VALUES kept in Portuguese for SEO (e.g. thepost.typeenum valuesnoticia|campanha|artigo|evento, tag/category slugs likesaude,eleitoral). Never translate those data/URL values or admin labels. Renaming a route segment folder changes only the param KEY, never the public URL (segment values come from the data).
Database & local development (safety-critical — production is a live Neon Postgres with real citizens' PII):
- Local dev and tests must NEVER touch production. Local dev runs against a local Docker Postgres (
pnpm db:start);pnpm devrefuses a non-localDATABASE_URL(guard:scripts/guard-dev-db.mjs) unlessALLOW_REMOTE_DB=true. Tests load.env.test(teqo_test) and refuse any database whose name doesn't end in_test(tests/helpers/assertTestDatabase.ts). Never repoint.env/.env.local/.env.testDATABASE_URLat Neon. Full workflow lives in thelocal-databaseskill (.agents/skills/local-database). - Schema changes go through committed Payload migrations —
push: falseeverywhere, never flip it on against a remote DB. Edit configs →pnpm migrate:create <name>→ commitsrc/migrations/*(both.tsand.json, plusindex.ts) →pnpm migratelocally.pnpm buildrunspayload migrate, so every Vercel deploy applies pending migrations to prod. Prod is baselined at migration20260715_163458_initial— never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in thepayload-migrationsskill (.agents/skills/payload-migrations). Consent.textwas reconciled fromvarchartojsonbvia20260715_163500_consent_text_to_jsonb(matching therichTextfield). Any future field-type change is a migration, not a manual DB edit.- To copy production content locally, use
pnpm db:pull(scripts/db-pull.mjs): it only reads prod, only writes local, and excludes supporter PII (contact/signature/subscriptionrow data). pnpm db:doctordiagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hostingteqo+teqo_test).pnpm devruns the same preflight viaguard-dev-dband refuses to start with a named remedy instead of a later ECONNREFUSED.db:start/db:stoppin the compose project with-p teqo, so they always target that ONE container even from a worktree directory.- Per-worktree environments (
pnpm worktree next, 2026-08-08; default-go 2026-08-10): parallel agents each get a deterministic isolated env — no fighting over port 3000, the sharedteqo_test, or a second Postgres container.worktree next [--stay]provisions: a dev-server port3100+slot(slot = numeric part of the issue code, hash fallback, bumped on collision among live worktrees), plus dedicated databasesteqo_wt<slot>(dev) andteqo_wt<slot>_test(tests) created on the shared container with the full migration chain applied; writes.env.local+.env.test.local(gitignored, marker# teqo worktree env (generated by pnpm worktree next); manual envs are never overwritten) and copiesPAYLOAD_SECRET/Blob/VAPID from the main repo.next/plan/newprintcd <dir>as the last line BY DEFAULT — the caller applies it (shell functionworktree()in.agents/shell/worktree.sh, or the/worktreeopencode command);--staysuppresses the cd,--goexplicit stays accepted as a no-op (it was the old default), andkillends withcd <main>so the shell never sits in a destroyed directory. **In the interactive terminal the shell function setsTEQO_WORKTREE_TERMINAL=1andnext/plan/newadditionally print alaunch opencode <dir> --model deepseek/deepseek-v4-flash --auto [--prompt <command>]directive BEFORE thecdline, which the function executes after the cd (OPS26, 2026-08-10; OPS31 forplan):nextopens the opencode TUI in the worktree with/work-issuealready sent (the OPS25 command runs the whole cycle),planopens with/plan-issuealready sent (the TUI lands straight in the planning flow),newopens with no prompt ("apenas conversar", no skill — the opencode CLI's--promptalways auto-submits, there is no prefill-without-submit; that stays an upstream-only feature request, annotated in the opencode proposition). The launch is withoutexec(exiting the TUI returns to the shell in the worktree, no auto-kill),--staysuppresses both the cd and the launch, the presets are constants inscripts/lib/worktree.mjs(OPENCODE_PRESET_MODEL,OPENCODE_SKILL_COMMAND_BY_PURPOSE, unit-tested), and the/worktreeopencode command never launches a TUI (no marker — no nested TUI).worktree plan [bag] [--stay](2026-08-08) is the sibling for the/plan-issueskill: each invocation creates a DIFFERENT planning worktree so parallel/plan-issuesessions never share one — branchplans/plan-issue-<bag>when abagslug is given (suffixed-2/-3if that name is taken) or the next free sequentialplans/plan-issue-<n>when not (PLAN_BRANCH_PREFIX/planBranchName,scripts/lib/worktree.mjs); the lowercaseplans/…prefix deliberately never matchesnext's uppercase-led<Code>-<slug>, so a laternextnever collides in branch or slot.worktree new [bag] [--stay](OPS27, 2026-08-10) is the NEUTRAL sibling: a worktree with **no pre-defined function** (explore an idea, chat, plan without registering anything) — also a DIFFERENT one per invocation, branchwork/<bag>(suffixed-2/-3on collision) or the next free sequentialwork/<n>(WORK_BRANCH_PREFIX/workBranchName, samenamespaceBranchNamecore asplan); the lowercasework/…prefix collides with neithernextnorplan, and the provisioning is identical withpurpose: 'new'(env commentgerado por pnpm worktree new; the frozen env marker keeps its historical…nexttext).killdrops the worktree's generated databases best-effort. Tests read.env.test.localafter.env.test(vitest.setup.ts / playwright.config.ts / db-doctor / gate-ci mirror the layering), andassertTestDatabaseacceptsteqo_testorteqo_<worktree>_teston localhost only — still fail-closed on remote. Pure derivation lives inscripts/lib/worktree-env.mjs(unit-tested). Without Docker (Cursor Cloud) it degrades to the sharedteqo/teqo_testwith a warning. - To (re)populate news content,
pnpm db:seed:postsfetches articles live from jorgesolla.com.br into the LOCAL db (same non-localDATABASE_URLguard aspnpm dev). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the productionpostscache viaPOST /api/revalidate. See "Posts & Tags" below. - To (re)populate the TSE 2022 election baseline,
pnpm db:seed:tsedownloads TSE open data and imports the Bahia scope into the LOCAL db (same guard). Since the 2026-07-23 hardening, elections-tab reads are cached under theelection-tsetag — after re-seeding a database the deployed app reads, bust it viaPOST /api/revalidate?tag=election-tse. See "Election baseline data (TSE 2022)" below.
Histórico de entregas (movido)
"Known Gaps" e os blocos "Recently resolved" saíram deste arquivo em 2026-07-30: vivem em docs/CHANGELOG-AGENTS.md. Novas entregas registram UMA entrada curta lá, não aqui. A referência genérica de Payload (config/collections/fields/hooks/queries/components) foi para docs/PAYLOAD-REFERENCE.md — consulta sob demanda, não always-on. Operação do paradigma de agentes paralelos (claim→PR→main→deploy gated): docs/AGENT-OPS.md.
Posts & Tags (news / publications)
The public news/publications system is backed by two collections (both in the Publicações admin group) plus the helpers in src/utilities/posts.ts.
post(src/collections/Post.ts) — fields:title,slug(unique, indexed, auto-slugified fromtitlewhen left empty),type(select enumnoticia|campanha|artigo|evento),category(required single relationship →tag),tags(hasManyrelationship →tag),subtitle,coverImage(upload →media),publishedDate,body(richText). Has drafts/versions (schedulePublish,maxPerDoc: 5).tag(src/collections/Tag.ts) — fields:name,slug(unique, indexed, auto-slugified fromname), andhidden(checkbox, admin label "Esconder", defaultfalse). Tags are the shared taxonomy for bothpost.categoryandpost.tags.
Tags are a taxonomy, not a person record. The Contact convention above applies to people; tag is publication metadata (categories + a visibility control flag), so post/tag relations do not touch Contact. Don't fold taxonomy into Contact or vice-versa.
Electoral visibility control (hidden + fail-closed isPostVisible). Marking a tag hidden hides every post that references it — as category or in tags — with a single toggle. This exists so campaign / pre-candidacy content (tagged eleitoral) can be pulled from the public site during the electoral period. isPostVisible(post) (src/utilities/posts.ts) returns true only when the post is published AND none of its related tags (category + tags) is hidden. It fails closed: an unpopulated relation (a bare numeric id) is treated as hiding, so callers must fetch with depth >= 1. Every public read filters through getVisiblePosts(), which applies this predicate to the cached published list.
URLs and Portuguese SEO values. The canonical article path is /[type]/[category]/[slug] (e.g. /noticia/saude/<slug>), with /[type] and /[type]/[category] listing routes and the home "Últimas notícias" list. The type enum values (noticia|campanha|artigo|evento) and tag/category slugs (saude, eleitoral, …) are deliberately Portuguese for SEO and are data, not identifiers — never translate them (see the naming-conventions bullet above). The route folders are [type]/[category]/[slug] (English param keys); the article route redirects any stale/mismatched URL to the canonical path derived from the post's current type + category slug. Article pages emit JSON-LD (Article) and Open Graph metadata.
Caching (mixed ISR, posts tag). Public reads go through the unstable_cache wrappers in src/utilities/posts.ts (getCachedPublishedPosts, getCachedPostBySlug), all tagged posts. The routes set export const dynamicParams = true and build generateStaticParams from getVisiblePosts(): known paths are statically generated, unknown ones render on demand, and everything stays cached until the posts tag is busted. revalidatePostsListing() (src/utilities/documents.ts) is revalidateTag('posts') (the listing tag is `${collection}s` → posts), so one call busts the home page, every listing route, and every article route at once.
Self-revalidation on admin edits. Both collections have an afterChange hook. post busts its own document tag + the posts listing on every publish/update (skipping only the initial draft that the admin create view generates during render). tag is broader: on change it re-reads every post referencing that tag (as category or in tags, passing req for transaction safety) and revalidates each one plus the listing — so flipping hidden is reflected on the site immediately.
Seeding news content (pnpm db:seed:posts). scripts/seed-posts.mjs (loaded via scripts/seed-loader.mjs) fetches ~39 articles live from jorgesolla.com.br (WordPress REST API, with an HTML-crawl fallback), imports them as type: 'noticia' published posts, converts the WP HTML body to Payload Lexical, and uploads cover images to Vercel Blob. The taxonomy (categories + the eleitoral control tag, and the per-slug classification) is hardcoded in the script; it reads/writes the hidden flag. It is idempotent by slug (existing posts are skipped; tags/media are reused by slug/filename) and reuses the same non-local DATABASE_URL guard as pnpm dev (override only with ALLOW_REMOTE_DB=true). The Vercel Blob store is shared across environments, so before uploading a cover it deletes any orphan blob left under the same deterministic <slug>.<ext> key, keeping the seed re-runnable.
Revalidating after a manual/direct DB change
Admin edits made through the deployed app self-revalidate via the afterChange hooks above. But any write that does not go through the deployed Payload runtime — direct SQL, pnpm db:seed:posts, a DB restore / db:pull — will not bust production's frozen posts cache. (Even when such a write triggers the hook, its revalidateTag('posts') runs in that CLI/local process and cannot touch the deployed server's cache.) The pages keep serving stale content until the tag is busted.
Bust it with the secured endpoint (src/app/(frontend)/api/revalidate/route.ts):
# News (`posts` tag — default)
curl -X POST "https://<prod-domain>/api/revalidate" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
# Privacy policy global after Onda 0 migration/seed (`global_privacy-policy`)
curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
- Auth: send the secret via
x-revalidate-secretorAuthorization: Bearer <secret>; it is compared toREVALIDATE_SECRETwith a constant-time check. REVALIDATE_SECRETmust be set in the Vercel production env (and in your local env if you want to test the endpoint). It is documented in.env.example.- Tag: optional
?tag=query or JSON body{ "tag": "..." }(query wins). Allowlist:posts(default),global_privacy-policy,election-tse(bust after re-runningpnpm db:seed:tseso the cached elections-tab reads refresh),municipality-catalog(bust after a migration/seed that adds or renames a município so chip indexes andunstable_cacheloaders refresh). Unknown tag →400. - Responses:
200 { revalidated: true, tag: '...' }on success;401if the secret is missing/wrong;500ifREVALIDATE_SECRETis not configured on the server. - This POST is the required last step of the post-seed / direct-DB-change runbook.
Campaign auth (/campanha)
The internal /campanha area is gated by its own authentication, deliberately kept isolated from the Payload admin (/admin) so a campaign session and an admin session can coexist in the same browser.
campaignUsercollection (src/collections/CampaignUser.ts) — a separate Payload auth collection withname, optional contactphone, and requiredrole:coordinator("Coordenador Geral"),advisor("Assessor"),candidate("Candidato"), orleader("Liderança" — default and least privilege). Staff may sign in by email; leadership accounts use the normalized 11-digit phone asusername. It is notusers:admin.userremainsusers, so campaign users cannot log into/admin. Its staticauth.tokenExpirationis the 14-day ceiling because Payload's Local API does not accept a per-login TTL anduseSessions(enabled) stores/prunes sessions using that configured expiry.- Isolated session cookie
campaign-token. The flow sets its own httpOnly cookie namedcampaign-token(constantCAMPAIGN_TOKEN_COOKIEinsrc/utilities/campaignAuth.ts), scoped topath: '/campanha',sameSite: 'lax', andsecureonly in production. It is deliberately NOT the defaultpayload-tokencookie — using a distinct name + path is what lets a campaign session and a Payload admin session live side by side without clobbering each other. Login defaults to 8 hours; checking "Lembrar de mim neste dispositivo" opts into 14 days.setCampaignAuthCookieowns the invariant JWTexp= cookiemaxAge: it re-signs the Payload token to 8 hours withjwtSignor keeps the original 14-day token. Password-reset and invite login paths omit the opt-in and therefore stay at 8 hours. - Session verification —
getCampaignUser()(src/utilities/campaignAuth.ts). Reads thecampaign-tokencookie, callspayload.auth({ headers }), accepts onlyuser.collection === 'campaignUser', then reloads that document by ID. The fresh read makes role downgrades effective immediately instead of trusting a stale JWT role. - Login/logout server actions (
src/app/(campaign)/campanha/actions/auth.ts).loginCampaignvalidates input withcampaignLoginSchema(src/lib/schemas/campaign-login.ts), callspayload.login({ collection: 'campaignUser' }), chooses the 8-hour / 14-day TTL, and — because the Local API returns a token WITHOUT setting a cookie — callssetCampaignAuthCookiebefore redirecting to/campanha.logoutCampaignauthenticates the current token and delegates revocation of its_sidto Payload's public transactionallogoutOperation, then clears the cookie (maxAge: 0) and redirects to/campanha/login; malformed tokens or database failures never prevent local cookie/PWA cleanup. - Route layout. The route group's root layout
src/app/(campaign)/layout.tsxrenders the<html>/<body>withdata-theme="campaign". The public login lives atsrc/app/(campaign)/campanha/login/(page.tsx+ clientLoginForm.tsx). Everything gated sits in the(app)group, whose layout (src/app/(campaign)/campanha/(app)/layout.tsx) callsgetCampaignUser()andredirect('/campanha/login')when there is no session — that layout is the barrier. - Biometric login (WebAuthn, B40 — 2026-07-28). A second way into the same session, never a second session: the biometric path ends at the same
setCampaignAuthCookiewith the 14-day TTL (enrolling the device is the trust opt-in), and the password path is untouched. Credentials live incampaignWebAuthnCredential(owner-only access insrc/utilities/access/webauthnCredentials.ts;updateis denied to users because the signature counter andlastUsedAtare written withoverrideAccess: trueinside the transaction). Four anonymous-or-authenticated JSON routes sit insrc/app/(campaign)/campanha/webauthn/— deliberately outside(app), since two of them run before there is a session and the cookie's/campanhapath already covers them. NoConsentkey, and this is not an art. 11 question: the OS never releases the fingerprint, so what we store is a public key.- There is no password-less login in Payload 3.82.0.
loginOperationrequirespassword.issueCampaignWebAuthnSession(src/utilities/campaignWebAuthnSession.ts) mints the session from public exports only:payload.db.findOne→checkLoginPermission→addSessionToUser(payload/shared) →getFieldsToSign+jwtSign. Never load the user withpayload.findByIDhere.addSessionToUserread-modify-writes the whole user document, so a doc that passed throughCampaignUser'safterReadremovePrivateAuthFieldswould be written back withoutsessionsoremail— logging the user out of every other device — and withavatarexpanded into an object.user.updatedAt = nullis set for the same reason Payload's own login does it. The mint runs insidewithPayloadTransactionundercampaign-webauthn-session:{userId}, because rewriting the wholesessionsarray makes two concurrent logins lose one. - RP ID must match the host that served the page.
getCampaignWebAuthnRelyingPartyderivesrpID/originfromgetCampaignInviteBaseURL, and compares the request's host against it — returningnullwhen they differ, which hides the button. Without that comparison a Vercel preview resolves the canonical origin successfully and the browser throwsSecurityErrormid-ceremony. - The challenge cookie must carry the library's own challenge. It is an HMAC-signed httpOnly cookie (5 min, single-use, path
/campanha, precedentsupporterImportToken.ts) — but store the exact base64url stringgenerateRegistrationOptions/generateAuthenticationOptionsreturned. Generating our own alongside theirs makes everyregisteranswer 400. - Deleting a
campaignUserneeds the cascade in a hook. Payload emitsON DELETE SET NULLfor the requireduserrelationship, which cannot apply to aNOT NULLcolumn;CampaignUserhas abeforeDeletethat removes the user's credentials in the same transaction. E2E uses a CDP virtual authenticator (WebAuthn.addVirtualAuthenticator,transport: 'internal'), which also makesisUserVerifyingPlatformAuthenticatorAvailable()answertrue.
- There is no password-less login in Payload 3.82.0.
- Migrations.
20260716_010420_add_campaign_usercreated the auth collection;20260728_041958_add_campaign_webauthn_credentialsadded the WebAuthn credential table; the unpublished 2026-07-17 campaign chain was consolidated into20260718_010733_consolidate_campaign_schema, which adds role/username auth, contact phone, and the remaining final campaign schema.
Campaign Municípios model (2026-07-23 remodel)
/campanha is organized around municipality ("Município") — the campaign's PREDEFINED operational territory. Deployed to production on 2026-07-23 (destructive remodel migrations applied via the Vercel build). Master plan: docs/plans/remodelagem-municipios.md (supersedes docs/plans/remodelagem-pracas.md).
- Município definition: 435 seeded rows — one per Bahia municipality (416), except Salvador (19 zone municipalities, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated). Canonical catalog:
src/lib/municipalityCatalog.ts(tests/fixtures/municipality-catalog.snapshot.json). Seeded by migration20260723_200000_remodel_municipalities(+ reconcile20260723_202000_reconcile_municipality_remodel). Geography is admin-only; staff edit strategy fields and the coordinator assignsadvisors. - Collections (admin group
Campanha):municipality,leadership,votePledge,organization,stateDeputy(dobradinhas),campaignDemand,allocationDecision,municipalityUpdate,campaignInvite,campaignWebAuthnCredential(biometric login — see "Campaign auth"),supporter,activity. Global (same admin group):campaignGoals(E8 "conta da cadeira" —stateGoalsets the optimistic suggested-goal scenario,marginthe pessimistic haircut;read= admin/staff,update= admin/isCampaignUnrestricted; noafterChangehook — nothing caches it). - Engagement level (E14):
municipality.engagementLevel(n0…n4, staff-only, no default) is the campaign's declared investment in the município — N0 monitorar … N4 investimento pleno. OnlyisCampaignUnrestrictedactors move it, every move needs a rationale plus the signals that would reverse it, and the move writes anallocationDecision(outcome: 'movimento') in the same transaction under an advisory lock. Hysteresis rules are pure (src/lib/engagementLevel.ts) and shared by client and server; the override is recorded with the rules it contradicted. Null means "Sem nível" — the triage queue, not a missing value to backfill. - Roles:
coordinator("Coordenador Geral") andcandidate("Candidato", unrestricted visibility viaisCampaignUnrestricted) see/manage everything staff sees;advisor("Assessor") is scoped tomunicipality.advisors;leader("Liderança") is lockdown — home is the supporter contact tool only (LeaderContactsPanel,source: lideranca), reads only supporters they created (createdBy), no municipalities/leaderships/pledges/demands/plans/election data. - Vote pledge asymmetry: staff declare
declaredVotesand recordestimatedVotes(pessimistic/central/optimistic); leaders never see estimates. Aggregates useestimated[S] ?? declared, defaultcentral(src/utilities/votePledgeData.ts). - Demandas: staff-only create/read (
canCreateCampaignDemand); optionalleadershipprovenance selected by staff. Escalated decisions: coordinator or candidate. - Dobradinhas:
stateDeputyentity +municipality.stateDeputies/leadership.stateDeputieshasMany; UI at/campanha/dobradinhas. - Map: staff dashboard (
/campanha) loadsMunicipalityMapPanel;/campanha/municipiosis list/overview only. Keyed by "map key", notibgeCode(B8 F2, 2026-07-26):mapKeyForMunicipalityreturns themunicipalitySlugforkind === 'zona'and theibgeCodeotherwise, so each of Salvador's 19 ZE is painted, hovered and opened on its own frombahia-municipality-zones.topo.json. Every bundle field follows that key (municipalitiesByMapKey,territorialClassByMapKey,projectedValidVotesByMapKey,valuesByYear, …) and each key maps to exactly one catalog entry, so rollups assign instead of accumulating. Salvador's municipal polygon is still drawn, underneath, as a non-interactive base with no data (baseKeysinBahiaMap) — two independently simplified meshes do not close on the same coastline, and deleting it opened a gap in the state outline; being out of the key index, it cannot double-count or steal a hover. Feature→key resolution isfeatureMapKey(municipalitySlug ?? codarea ?? code) — the three are mutually exclusive on the committed meshes, so it is a lookup over distinct names and not a precedence rule; the zone mesh also carries the city'sibgeCode, which is deliberately not a key property because all 19 zones share it. Since B8+ that contract is exercised on real features of all three meshes intests/int/bahiaGeometries.int.spec.ts— over the full 417 / 27 / 19, not on hand-written literals and not on one city feature repeated per zone — and a failure of the zone chunk degrades instead of killing the map:loadLayerFeaturescatches it and paints the state without the ZE (Salvador falls back to its non-interactive base polygon, not to an aggregated one — the bundle is keyed by slug), whilestatus === 'error'stays reserved for the municipal mesh.competitiveRankis still per city: the TSE artifact only carriesfederalRankByIbgeCode, so all 19 zones share the city's placement — which is what the legend already claims, and an int test pins it. Scales (B13): quantis (default), padrão próprio (LQ), posição no município, % dos válidos (B11) and Total (votos). The three relative ones are discrete classes, never a continuous ramp — the pure math lives insrc/lib/mapScaleClasses.ts(quantiles degrade to 3 classes below 10 municípios and the legend says so; LQ is cut on the E10 anchors insrc/lib/territorialClassAnchors.ts). Diverging red×blue stays exclusive to the compare mode, where it means "Solla × adversário" — do not reuse it for LQ. Quantiles are computed over the user's access scope, so an advisor sees his portfolio classed against itself. Rank is unavailable in 2026 (TSE data) and LQ in 2026 reuses the 2022 statewide standard, the same precedentpercentValidset. An optional proportional-symbol layer sizes bubbles by projected valid votes (area ∝ value) and colours them by the E10 class. A class label never renders alone: the readout always spells out the reason. - URLs:
/campanha/municipios[/slug],/campanha/dobradinhas,/campanha/liderancas[/id],/campanha/organizacoes[/slug],/campanha/demandas[/slug],/campanha/atividades[/slug],/campanha/atividades/giros(compositor de giro, staff),/campanha/apoiadores,/campanha/assessores(coordinator/candidate),/campanha/conceitos(staff glossary),/campanha/perfil.
Campaign supporters (C2)
Nominal supporter registry backing the campaign's base of declared supporters (roadmap item C2; engineering ready and merged to main on 2026-07-18, production-blocked on the legal batch). One collection in admin group Campanha:
supporter(src/collections/Supporter.ts) — joinContact↔campaign with optionalmunicipality. Fields includesource(import_csv | manual | convite | evento | liderancafor leader-created rows).- Consent by stable key.
campaignConsent.tswas generalized togetConsentByKey/requireConsentByKey(fail-closed). The two keys areapoiador-cadastro(declared support) andapoiador-intencao-voto(vote intention — highlighted, separate consent). The app fails closed if either key is missing — same pattern aslideranca-autopreenchimento. - Access.
coordinator/candidatesee/manage all;advisorsees supporters of administered municipalities;leadermay create supporters in linked municipalities and read only rows they created (createdBy); supporters without a municipality remaincoordinator-only. Delete is admin-only. - Server actions (
src/app/(campaign)/campanha/actions/supporter.ts):createSupporter,setSupporterVoteIntention,previewSupporterImport/confirmSupporterImport(CSV,coordinator-only, transactional withreq: { transactionID }, dedup by phone viacontactPhoneInvariant), andremoveSupporterData(deletes thesupporter; anonymizes/clearsContactPII only if no other join —leadership/signature/subscription/othersupporter— references it). - UI
/campanha/apoiadores— list with KPIs (total / "Certo + Tende" / "Indecisos"), filters (vote intention, territory), detail with LGPD consent block + vote-intention segmented control (disabled until consent) + minimal share kit (wa.me+ copy text), and a 3-step CSV import wizard (Upload → Preview → Confirm). Phone is required in v1. - C6 scale layer (merged to
main2026-07-19). CSV import up to 5000 rows now completes in one transaction:confirmSupporterImportRecordacquires all phone advisory locks, thenbulkInsertSupporterImport(src/utilities/supporterImportBulk.ts) inserts contacts + supporters viapayload.db.drizzleon the Payload transaction session (getPostgresTransactionDatabase) in 500-row chunks withON CONFLICT DO NOTHINGon(contact_id, municipality_id).Contact.enforceUniqueContactPhonehonorscontext.skipContactPhoneInvariantfail-closed (only inside an activereq.transactionID), so the bulk path skips the per-row re-check while the single-create path is unchanged. Preview no longer round-trips the fullokset:previewSupporterImportTextstages it insupporterImportBatch(src/collections/SupporterImportBatch.ts, admin-hidden,coordinator/admin only, migration20260719_011015_add_supporter_import_batch) and returns an HMAC-SHA256 single-useimportToken(10-min TTL, actor-bound,src/utilities/supporterImportToken.ts);confirmSupporterImportRecordverifies the token and consumes the batch (delete after commit). The list KPI is a singleCOUNT(*) FILTERaggregate (src/utilities/supporterListOverviewAggregate.ts) mirroringbuildSupporterListWhere+ the access constraint, replacing 3×payload.count. Shared shells —campaignListUrl.ts,CampaignListPagination,campaignFormFields.ts,mapCampaignFormActionError.ts— replace the three per-list pagination components and the duplicated form-error ladders. Scale/DRY follow-ups from the/simplifypass are registered as C8 (docs/plans/escala-dry-pos-c6.md). - Mandatory production blocker: do not import real supporter data or capture vote intention until electoral counsel documents the LGPD art. 11 basis (vote intention is sensitive data), approves the versioned consent texts, and an admin creates
Consent.key = 'apoiador-cadastro'andConsent.key = 'apoiador-intencao-voto'. The app intentionally fails closed when these keys are absent.
Campaign activities (C3, renamed by C13)
Mobilization events / agenda backing the campaign's field operations (roadmap item C3; implemented and merged to main on 2026-07-18). Renamed "Plano de Ação" → "Atividade" on 2026-07-25 (C13): the collection slug is activity, the route is /campanha/atividades, and the vocabulary guard in tests/unit/codebaseConventions.unit.spec.ts now fails the build on actionPlan / plano de ação / /campanha/planos anywhere in src, tests or scripts (migrations excluded — frozen history). One collection in admin group Campanha:
activity(src/collections/Activity.ts) — a calendarizable action/event (caminhada, comício, etc.) anchored on ONEmunicipality, with task checklist, update feed and result record. Fields:title, canonical immutableslug,kind/statusenums in pt-BR,startAt(required beyondrascunho),municipality(required) + free-textlocality,organizations(hasMany — supporting orgs),advisors(staff responsible; ex-coordinators),deputyPresent(checkbox "Deputado presente"),responsible→Contact,leadershipoptional,tasks/updatesarrays (derived fields server-side), and the action result:resultSummary+resultMedia(upload →media) + derivedresultRecordedBy/At— queryable history for future activities in the same município. NoConsent(internal staff data).- Access.
coordinatoreverything;advisorwhere listed inadvisorsOR the activity'smunicipalityis administered (may create, auto-included);leaderhas no access (lockdown). - UI
/campanha/atividades— list with tabs Próximos (default) / Todos / Realizados / Rascunhos, filters (kind, municipality), cards with kind/status badge + date/time + município + "Resp:" + task progress + "Deputado presente"; detail with tabs Visão geral / Tarefas / Atualizações (/campanha/atividades/[slug], create at/campanha/atividades/nova); result form appears for staff when the activity isrealizado.
Campaign map geometries (B2)
Static Bahia map foundation for future Leaflet surfaces (roadmap B2; UI is B3). No migration, collection, Consent, or server action — same static-data pattern as bahiaTerritories / bahiaTseZones.
- Artifacts (committed):
src/lib/geometries/bahia-municipalities.topo.json(~132 KB; Topology objectmunicipalities, 417 features,properties: { codarea, name }),src/lib/geometries/bahia-identity-territories.topo.json(~15 KB; objectterritories, 27 features,properties: { code, name }) and — since B8 F2 (2026-07-26) —src/lib/geometries/bahia-municipality-zones.topo.json(~7.7 KB since B8+ recalibrated it; objectmunicipalityZones, 19 features,properties: { municipalitySlug, name, ibgeCode }), the Salvador ZE mesh. - Ground-scale policy (B8+, 2026-07-27): how much detail a committed mesh keeps is declared in ground units in
scripts/lib/topology.mjs(the first module inscripts/lib/— B5 F2 lands there rather than opening a second one), and both build scripts consume it. Two knobs, both meaningless as raw numbers:topojson-simplifycan pick its Visvalingam threshold as a quantile of the mesh's own triangles, andquantize(topo, n)lays ann-step grid across the topology's own bbox — so the0.35and1e4copied verbatim between the two scripts cut Salvador at ~0.6 m² and ~3.9 m while cutting Bahia at ~1.78 km² and ~103 m. The zone mesh shipped at 480 points/feature against the state mesh's 21.9 because of it. NowSIMPLIFY_TOLERANCE_M2states a tolerance per mesh in square metres of ground (with the density it buys measured in the comment) and quantization digits are derived per topology fromGROUND_GRID_METRES. Two things not to re-derive: quantization alone barely helps (even on the state grid the zones stayed at 203 points/feature — the absolute weight is the lever), and simplify before quantize, because quantizing a dense mesh to a ~100 m grid collapses vertices into duplicates and a latermergeproduces broken rings (measured: Salvador dissolving to 24 km² instead of 307). Tolerances differ per mesh on purpose: the municipal tolerance reaches 17.9 points/feature on the zones but sheds 5% of Salvador's land area (306 → 291 km²), which is the quantity the int spec uses to prove no neighbourhood was dropped or double-counted. Guard:MAX_POINTS_PER_FEATURE = 60intests/int/bahiaGeometries.int.spec.ts, next to the byte budget (soft, ≤ ~600 KB each) — bytes only catch a mesh nobody simplified at all. - Code table:
src/lib/bahiaMunicipalityCodes.ts— canonical municipality name → IBGE 7-digitcodarea, pluscodeForMunicipality/municipalityForCode. Fixturetests/fixtures/bahia-municipality-codes.official.json+ int tests for bijection/coverage. - Runtime helpers:
src/lib/bahiaGeometries.ts— typed topologies, feature arrays andgetMunicipalityFeature(the territory and zone accessors were deleted by B8+: only the int specs used them, and aMapbuilt in one line is what a spec wants). TopoJSON→GeoJSON decode is LAZY (dynamic import vialoadMunicipalityGeometryModule/loadMunicipalityZoneGeometryModule; B5 F1 done) — the default/campanhaserver graph never pays it. Shared feature types live inbahiaGeometriesTypes.ts:BahiaFeature<P>is the one spelling ofFeature<Polygon | MultiPolygon, P>,BahiaMeshFeatureis what the map layer receives from any of the three meshes, and geometry math (point-in-polygon, centroid, area, bounds — all built on the exportedpolygonRingsOf) takesPolygonalFeature, which declares the actual requirement instead of draggingGeoJsonProperties(i.e.any) through the signature. - Territory polygons: dissolved from IBGE municipality meshes via
bahiaIdentityTerritoryRecords(topojsonmerge), not the IDE Bahia shapefile (IDE remains provenance/validation reference only). - Rebuild (Salvador zones):
pnpm build:municipality-zone-geometries(scripts/build-municipality-zone-geometries.mjs;--reportreconciles without writing) downloads IBGEBA_bairros_CD2022.zipfrom geoftp, parses the shapefile withshpjs(devDependency — that cut is SHP-only, SIRGAS 2000), keepsCD_MUN === '2927408'(170 polygons) and dissolves neighbourhood→zone against TRE-BA RA 02/2017 (municipalityZoneNeighborhoods.ts). Reconciliation is curated data in the script header, not cleverer matching: an alias table, a table for TRE neighbourhoods with no polygon of their own (the build refuses a merge that would cross zones), and a table for the one neighbourhood the resolution splits mid-street (it goes whole to one ZE — the neighbourhood is the atom). The 5 polygons the resolution never names are assigned by longest shared boundary, a rule validated against the 4 it names under another label. The build fails on an orphan polygon or a TRE neighbourhood with no polygon, so 170/170 → 19/19 is an invariant, not a hope. Same cache dir and the same "does not touch the database" guarantee as the sibling below. - Rebuild:
pnpm build:geometries(scripts/build-bahia-geometries.mjs) downloads IBGE Malhas (qualidade intermediaria) + Localidades, reconciles names withcanonicalizeMunicipalityName, simplifies/quantizes, emits the artifacts above. Cache underdata/geometries/(gitignored; override withGEOMETRIES_CACHE_DIR). Does not touch the database — noassertLocalDatabase/ no Neon risk. Not part ofpnpm buildorpnpm dev. Details:docs/plans/mapa-bahia-geometrias.md. Scale/DRY follow-ups from/simplify:docs/plans/escala-dry-pos-b2.md(B5).
Election baseline data (TSE 2022)
Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Three collections in admin group Dados Eleitorais: electionTally (per city×zone totals: eligible voters, turnout, valid/blank/null votes), electionCandidateVote (nominal votes per candidate×city×zone), and electionCandidate (candidate registry with a cross-year identityKey hash — no CPF or voter ID is ever persisted). Read access requires an authenticated campaignUser or users account (canReadElectionData); writes are Payload-admin-only, and the import runs via CLI with overrideAccess: true. Since the Município remodel, the consumers are municipalityElectoralBaseline.ts (per-municipality series/turnout), municipalityCandidateComparison.ts (multi-candidate table) and municipalityMapData.ts (map, incl. the diverging compare mode); ticket roles live in BASELINE_TICKET_2022. Seeds cover 2014/2018/2022 (E2 extended the original 2022-only A3 scope). Two caching layers sit on top (2026-07-23 hardening): the immutable Solla/valid-votes series per municipality ships as a committed artifact — pnpm build:election-aggregates → src/lib/electionAggregates/bahia-federal-baseline.json, loader src/lib/bahiaElectionAggregates.ts, snapshot-tested against municipalityCatalog — so the map/dashboard never query these collections for historical years; and the elections-tab loaders (municipalityElectoralBaseline, municipalityCandidateComparison, electionCandidateOptions) are wrapped in unstable_cache under the election-tse tag (bust via POST /api/revalidate?tag=election-tse after re-seeding).
The artifact is at v3: v2 added E8's campoFederalVotesByYear / federalTallyByYear / majoritarian2022, and B13 (2026-07-26) added federalRankByIbgeCode — the candidate's placement among every federal-deputy candidate with votes > 0 in that geography, per year, keyed by IBGE code (417 codes; Salvador's 19 zone slugs sum to the whole city, which is what the map draws) and read through getFederalCompetitiveRank. A geography where he had no votes gets no entry, never rank: 0. Its input is loadFederalVotesByCityZoneAndCandidate in municipalityElectoralBaseline.ts — the sibling of loadCampoFederalVotesByCityZone, same statewide scan, CLI/build-time only. The file is 623 KB against the 700 KB budget asserted in tests/unit/bahiaElectionAggregates.unit.spec.ts; anything new that would blow it needs a real measurement, not a bumped constant. Rank exists only by IBGE code — add a per-slug variant when the dossier or E11 actually needs one.
pnpm db:seed:tse (scripts/seed-tse-results.mjs) downloads the TSE open-data zips (provenance URLs + SHA-256 in the script header), parses the Bahia scope, and imports via payload.db.drizzle in a transaction — idempotent by replace-per-scope (year, office, turn), only voteType: 'nominal' rows in v1. It refuses a non-local DATABASE_URL (same guard family as the other seeds; override with ALLOW_REMOTE_DB=true) and caches downloads under data/tse/ (gitignored). No revalidate step is needed: campaign pages are dynamic with auth, not ISR-cached.
Local verification and deploy checklist
- Confirm
DATABASE_URLispostgresql://teqo:teqo@localhost:5432/teqoand.env.testpoints toteqo_test; never use Neon for development, tests, E2E, or local builds. In a provisioned worktree (pnpm worktree next) the values come from its.env.local/.env.test.local(teqo_wt<slot>/teqo_wt<slot>_test, dev port3100+slot) — read them there instead of assuming the main-repo names. - Run
pnpm db:start,pnpm migrate:status, andDATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status; every migration must showRan: Yes. - Run
pnpm generate:types,pnpm generate:importmapwhen components changed,pnpm exec tsc --noEmit,pnpm lint(zero warnings — the script enforces--max-warnings=0),pnpm format:check(Prettier is the formatting authority;pnpm formatfixes),pnpm exec knip(dead files/dependencies are CI-blocking; delete what your change orphaned),pnpm check:cycles(madge — zero circular imports),pnpm test(unit + int),pnpm test:e2e(orpnpm test:all), andpnpm buildagainst the local database. Payload CLI scripts (migrate,generate:*,payload) and the build migrate step use--conditions=react-serversoserver-onlymodules load correctly outside Next.js.- Never run
knip --fixblind: when knip's module graph is incomplete (it still cannot loadpayload.config.ts— ledgered as P3),--fixdeletes exports that migrations/tests still reference. Verify each removal (e.g.git grep -w <symbol>) before deleting. - Run gate commands bare — never piped (
pnpm test | tailswallows the exit code, and a real failure was masked exactly this way during Pass 2). Large outputs are captured by the terminal; read them from there.
- Never run
- Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change.
- Review Vercel environment variables and migration SQL before deploy;
pnpm buildapplies pending migrations to production automatically.NEXT_PUBLIC_SITE_URL(ex.:https://pt.jorgesolla.com.br) é obrigatória em Production — sem ela,getCampaignInviteBaseURL()lança em runtime e o fluxo de convites/webauthn/link de import da agenda falha com mensagem genérica (C98, 2026-08-09: produção ficou sem a env e "gerar link" do feed quebrava; o preview tinha a de stage). - Mandatory production blocker: do not insert real leadership/support data or enable invites until electoral counsel documents the LGPD art. 11 basis, approves the specific versioned consent text, and an admin creates it with exactly
Consent.key = 'lideranca-autopreenchimento'. The app intentionally fails closed when this key is absent. - Remodel deploy (historical — applied to production 2026-07-23): the destructive remodel chain (
20260721_020109_remodel_municipalities→20260723_200000_remodel_municipalities+20260723_202000_reconcile_municipality_remodel) already ran in production via the Vercel build: campaign vertical reset, 435 municípios seeded, public-site data untouched. Treat any future destructive migration the same way — review its SQL before the first production build that includes it.
Core Principles
- TypeScript-First: Always use TypeScript with proper types from Payload
- Security-Critical: Follow all security patterns, especially access control
- Type Generation: Run
generate:typesscript after schema changes - Transaction Safety: Always pass
reqto nested operations in hooks - Access Control: Understand Local API bypasses access control by default
- Access Control: Ensure roles exist when modifiyng collection or globals with access controls
Code Validation
- To validate typescript correctness after modifying code run
tsc --noEmit - Generate import maps after creating or modifying components.
- Any change to a collection/global/field schema requires a migration:
pnpm migrate:create <name>, thenpnpm migratelocally. Never rely onpush(it isfalse). See thepayload-migrationsskill.
Project Structure
Actual layout (campaign access control lives in src/utilities/access/* domain modules with src/utilities/campaignAccess.ts as the re-export surface; hooks are still written inline inside each collection/global file):
src/
├── app/
│ ├── (frontend)/ # Frontend routes + server actions (src/app/(frontend)/actions/*.ts)
│ ├── (payload)/ # Payload admin routes
│ └── (campaign)/ # Internal campaign-management area (auth barrier, see "Campaign auth")
├── collections/ # Users, CampaignUser, CampaignWebAuthnCredential, CampaignInvite, Municipality, Leadership, VotePledge, MunicipalityUpdate, Organization, StateDeputy, CampaignDemand, AllocationDecision, Supporter, SupporterImportBatch, Activity, ElectionTally, ElectionCandidateVote, ElectionCandidate, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag
├── globals/ # Global configs (SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals)
├── components/ # campaign/<domínio>/ (municipality, activity, supporter, …, shared, shell — Pass 2 W2), ui from shadcn
├── lib/ # cities.ts, bahiaTerritories.ts, bahiaTseZones.ts, bahiaMunicipalityCodes.ts, bahiaGeometries.ts, geometries/*.topo.json, municipalityCatalog.ts, electionAggregates/ (committed TSE artifact) + bahiaElectionAggregates.ts, formData.ts, zod schemas (lib/schemas/*)
├── utilities/ # campaign*, access/* (per-domain RBAC; campaignAccess.ts re-exports), municipality*, posts, documents, globals, locks, etc.
└── payload.config.ts # Main config