Generate the Payload importMap at build time (Dockerfile) and dev time (predev hook) with dummy S3_* envs, eliminating the class of bugs where the admin goes blank because the S3ClientUploadHandler entry is missing (OPS69/OPS72/OPS73). Changes: - Dockerfile: add pnpm generate:importmap with dummy S3_* envs before next build - scripts/predev-importmap.mjs: predev hook that generates importMap if missing or stale - Delete scripts/check-importmap-s3.mjs (guard no longer needed) - Delete tests/unit/importMapS3UploadHandler.unit.spec.ts (unit pin no longer needed) - Remove check-importmap-s3.mjs step from CI workflows - Add importMap.js to .gitignore (build artifact, not committed) - Update AGENTS.md with new contract Closes #928
18 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. -
Production is self-hosted on the homeserver (
jorgesolla1313.com.br, behind the Cloudflare tunnel; container/databaseteqo-1313/teqo_1313). There is no Vercel deploy anymore — the Vercel deploy job,vercel-promote.yml, the Vercel scripts/vercel.jsonand theVERCEL_*repo secrets were removed in OPS50 (2026-08-17). CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21) (github.com/fsolla/teqo/issues— labels, claims,pnpm issue/agent:*and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (ci-pr.yml, job únicochecks— OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/curated, skips viaci-scope.mjs; OPS86: diff high-risk roda o conjunto curado de e2e — nunca zero —, área de risco sem mapping falha fechadounmapped-risk, e unit/intchangedcom seleção vazia cai em full viavitest-changed-or-full.mjs); its check-runCI (PR) / checksis the required check ofmain— literal de match na proteção:checks(o GitHub casa pelo nome do check-run/job; a UI exibeCI (PR) / checks— pin ao vivo PR #742) — and the gate of the native auto-merge (agent-pr-ready-automerge.yml→scripts/github-pr-automerge.mjs, GraphQLenablePullRequestAutoMerge, rebase — armed with theAUTOMERGE_PATreal-user PAT, fail-closed without it; the built-in GITHUB*TOKEN would make the merge happen asgithub-actions[bot], whose events never create workflow runs — the post-mergeclosedflips would silently never run, live finding PR #746 (OPS71-FLIP) — the server only merges with the required check green). There is NO main verifier (ci.yml) anymore: publishing is a manualworkflow_dispatch(.github/workflows/deploy.yml) with two jobs —verify(hostedubuntu-latest, suíte full incl. e2e full) →deploy(needs: [verify],runs-on: [self-hosted, homeserver]): the GitHub self-hosted runner ON the homeserver executesscripts/deploy-homeserver.shlocally (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). The remote script is idempotent ("already deployed" → skip), builds the migrator image first, applies pending migrations through the compose maintenance serviceteqo-1313-migratebefore the runner build (OPS66: the static build reads the new schema — build-after-migrate, never the reverse), builds the standalone image at the pushed SHA (BuildKit secrets from~/stack/teqo-1313.env), pushes it to the local registry (localhost:5000), and recreates the container; the repo comes fromTEQO_REPO_URL(defaulthttps://github.com/fsolla/teqo.git, public).pnpm pushpushes toorigin— the GitHub remote (one-time local cutover:git remote set-url origin git@github.com:fsolla/teqo.git; the Forgejo stays as a frozen secondary repo at the cutover point, its git repo and issue tracker inactive — OPS76 migrated the tracker to GitHub). The Forgejo-era files (.forgejo/workflows/,forgejo-pr-automerge.mjs,forgejo-dispatch.mjs,ci-classify-production.mjs) were removed in the OPS71 Fase 2 (2026-08-19); rollback to the Forgejo CI flow is a revert of that removal — the frozen Forgejomainstill holds the full pre-cutover tree. Runbook (rollback, falhas conhecidas, passos do cutover):docs/ops/teqo-1313-deploy.md. The agent-pool supervisor workflow was killed in OPS65 (the*/10tick cost 144 fullpnpm install/day on the workstation;agent-pool.ymlis gone, pool scripts/skills stay dormant). The production database lives on the homeserver Postgres (teqo\*1313, migrated from Neon in OPS51, 2026-08-17) — the full production content (contacts, signatures, subscriptions, campaign) was restored there viapg_dump/pg_restore(dump in/srv/hdd/backups/teqo-neon-pre-migracao/); Neon remains alive and untouched as the documented rollback (re-pointDATABASE_URLin~/stack/teqo-1313.env). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): themediacollection uploads via@payloadcms/storage-s3when theS3\*\*envs are set, and Payload proxies the files through/api/media/file/...(the bucket stays private). Production env lives in~/stack/teqo-1313.env(bucketteqo-media, endpointhttp://host.docker.internal:3900— o container alcança o Garage no host viaextra_hosts: host-gateway+ regras ufwallow from 10.0.11.0/24 to any port 3900(INPUT e route/FWD); a workstation/tailnet usahttp://100.119.220.31:3900, keyteqo-key); a partialS3\__config aborts the boot, and noS3\_\*means local disk (dev/test). The 40 pre-existing media rows are RECOVERED (OPS52-media, 2026-08-18): 38/40 objects restaurados no bucket a partir do WordPress (38 capas servindo 200 em/api/media/file/...; exceções sem fonte:fim-escala-6x1.jpgejorgesolla.jpg) — re-executar viapnpm media:recover(restaura objetos sem tocar no DB; runbook emdocs/plans/ops52-media-recuperar-arquivos-impl.md; desde OPS52-media-guard o reconcile exigeMEDIA_RECOVER_CONFIRM=1— guard de intenção explícita para escrita no bucket,--dry-run/--verifyseguem sem flag); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (guard-dev-db,assertTestDatabase) still enforcelocalhost/\_test-suffixed targets. -
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. - 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 the homeserver Postgres teqo_1313 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 the homeserver prod DB (teqo_1313). Full workflow lives in thelocal-databaseskill (.agents/skills/local-database). pnpm devhonra oPORTdo.env.local(OPS40):next devresolve a porta ANTES de@next/envcarregar os env-files (commander.env('PORT')nobin/next), então oPORT=3100+slotdo provisionador de worktree era ignorado e todo dev server subia em 3000.pnpm devhoje passa porscripts/dev.mjs: roda o mesmo preflight doguard-dev-db, carrega.env.local→.env(override:false— env real do processo vence, como oPORTque o webServer do Playwright injeta) e entrega a porta aonext devcomo flag CLI-p(resolveDevPort/nextDevArgsemscripts/lib/cli.mjs, unit-testados); semPORTem lugar nenhum, nenhum-pé passado e o default 3000 (comallowRetry) do Next segue intacto no main repo.- 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 production 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.
Histórico de entregas (movido)
"Known Gaps" e os blocos "Recently resolved" saíram deste arquivo em 2026-07-30: o registro commitado vive em docs/changelog/ (uma entrada por arquivo, imune a conflito). O agregado legível docs/CHANGELOG-AGENTS.md é gitignored e gerado sob demanda (OPS85): pnpm changelog:build grava, pnpm changelog:read imprime no stdout — com seed do snapshot congelado docs/CHANGELOG-AGENTS-HISTORY.md. Novas entregas registram UMA entrada curta em docs/changelog/<data>-<id>.md — nunca editam docs/CHANGELOG-AGENTS.md nem o HISTORY na mão. 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.
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 - ImportMap is a build artifact (OPS99): Generated at build time (Dockerfile with dummy S3_* envs) and dev time (
pnpm predev). Never commitsrc/app/(payload)/admin/importMap.jsto git — it's in.gitignore. Thepnpm predevhook ensures it exists beforepnpm devstarts. If you need to regenerate manually, run:S3_BUCKET=x S3_ENDPOINT=http://127.0.0.1:3900 S3_ACCESS_KEY_ID=x S3_SECRET_ACCESS_KEY=x pnpm generate:importmap - 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
Per-worktree environments
pnpm worktree next provisions isolated envs per worktree: dev port 3100+slot, databases teqo_wt<slot> / teqo_wt<slot>_test on the shared container with full migration chain + minimal seed, .env.local + .env.test.local (gitignored), copies of PAYLOAD_SECRET/S3_*/VAPID from main repo. Same provisioning for plan [bag], new [bag] and fix [bag] worktrees (plans/plan-issue-…, work/…, fix/… namespaces — fix opens /bug-fix with the bug description delivered; no claim, no Issue). The skill local-database covers the full workflow.