Add the tooling gate: knip, CI, zero-warning lint, as-never ban

- knip (files/deps at error level; unused exports ledgered as warn) with
  config for Payload/Next entries; deleted the dead modules it confirmed:
  RecentlyVisited + UpcomingActionPlansCard + actionPlanUpcomingPreview,
  lazy-dialog helpers, tseZone, federalCandidateTotalsAggregate (test-only),
  unused ui kit files, one-shot rename script; pruned unused radix deps and
  typescript-eslint; added pg/@types/pg (pinned to drizzle's 8.10.2)
- GitHub Actions CI on the mirror: lint, typecheck, knip, unit, migrate, int
  against a Postgres 17 service
- eslint: unused-vars with ^_ ignore patterns (migrations args exempt),
  repo-wide ban on `as never` (no-restricted-syntax), lint gate at
  --max-warnings=0; fixed all 54 pre-existing warnings (dead imports,
  hooks deps, tracking-pixel img disable with impeccable config exception)
- tsconfig: include consolidated from 219 lines to 5 (wildcard covers all
  .next dist variants), noUnusedLocals on
- typed replacements for every as-never: hookFilledCreateData (actions
  create data completed by collection hooks), drizzle scopeColumn narrowing,
  tests' stub<T> helper (property-level checking kept, completeness waived)
- test infra: leased-consent generalization used by onda0 spec (shared
  leases over the stable consent keys; window race ledgered), purge fix
- docs: IMPROVE-CODE-QUALITY-PLAN.md tracker, TECH-DEBT.md ledger,
  TESTING.md safety-net map (package.json also gains the Phase 2
  build:election-aggregates script entry; the script lands next commit)
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-23 23:06:12 -03:00
parent 473c82d09d
commit 0e4e6855dc
58 changed files with 1291 additions and 1906 deletions

68
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,68 @@
# Continuous integration for the GitHub mirror (pushes go to Codeberg + GitHub).
# Runs the same gate as .husky/pre-push plus tests and dead-code analysis.
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
# Matches production Neon major (17) and docker-compose.yml.
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
env:
# Tests load .env.test themselves (database teqo_test on localhost:5432).
# These are only for the migrate step, which reads the process env.
DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint (zero warnings)
run: pnpm lint
- name: Typecheck
run: pnpm typecheck
- name: Dead code (knip)
run: pnpm exec knip
- name: Unit tests
run: pnpm test:unit
- name: Migrate test database
run: pnpm migrate
- name: Integration tests
run: pnpm test:int

View file

@ -11,6 +11,15 @@
],
"createdAt": "2026-07-21T01:54:11.928Z",
"reason": "Pre-existing inline hex in the password-reset EMAIL HTML template; transactional email cannot use CSS tokens"
},
{
"rule": "broken-image",
"value": "*",
"files": [
"src/components/MetaPixel.tsx"
],
"createdAt": "2026-07-23T22:40:00.000Z",
"reason": "Meta's documented noscript tracking pixel: a hidden 1x1 <img> whose src is a templated facebook.com/tr URL — not a rendered image"
}
]
}

View file

@ -0,0 +1,46 @@
# Improve Code Quality Plan
## Context
- **Started:** 2026-07-23
- **Trigger:** full-codebase engineering-standards review (five parallel audits: structure/dead code, RBAC, client boundary/state, loading UX, caching) of a rapidly built ("vibe coded") codebase.
- **Scope:** whole repo; production is live (Neon Postgres with real citizens' PII), so security ordered first.
- **Method:** `improve-code-quality` journey, compressed into the seven delivery phases below (mapping to the skill's phases noted per row). The audits replaced a separate intake; the existing test suite (238 unit + 345 int + 5 e2e specs) is the safety net.
- **Companion docs:** [TECH-DEBT.md](TECH-DEBT.md) (ledger), [TESTING.md](TESTING.md) (safety-net map), [roadmap.md](roadmap.md) (canonical product/engineering backlog — larger debts become roadmap items per the `capture-review-debts` convention).
## Phase Status
| Phase | Content | Skill phases | Status | Date |
| ----- | ------- | ------------ | ------ | ---- |
| 0 | Security lockdown: collection access defaults, leader route gates, broken leader-supporter create, candidate alignment | — (P0 pre-work) | done | 2026-07-23 |
| 1 | Tooling gate: knip + dead code, CI, tsconfig, zero lint warnings, `as never` ban, docs artifacts | 1 (net) + 2 (readability) + 6 (habits) | in-progress | 2026-07-23 |
| 2 | Caching: React `cache()` dedup, committed election-aggregate artifact, `unstable_cache`, select/depth trims | 8 (sizing) | pending | |
| 3 | Loading feedback: task-toggle revalidation bug, map compare transition, result-region pending, Suspense streaming | — (UX correctness) | pending | |
| 4 | Client boundary/state: map hover isolation, bundle slimming, provider narrowing, composition splits | 4 (deep modules, UI) | pending | |
| 5 | Structure: split campaignAccess + supporter actions, dedupe clones, utilities organization, finish rename | 3 (refactoring) + 4 (deep modules) | pending | |
| 6 | Documentation: engineering-standards rule, AGENTS.md drift fixes, debt registration | 6 (habits) | pending | |
Statuses: pending · in-progress · awaiting-evidence · done · deferred: \<reason\> · skipped: \<reason\>
## Key Decisions
| Date | Phase | Decision | Rationale |
| ---- | ----- | -------- | --------- |
| 2026-07-23 | 0 | Collection access lockdown before anything else | Payload defaults allowed any authenticated user (incl. campaign JWTs) to CRUD `users`/`signature`/`subscription`/`consent` via `/api` — live PII |
| 2026-07-23 | 0 | Keep pinned test contracts; stale tests updated, not silently changed code | Two leader-phone tests encoded the pre-remodel model; code was right, tests were stale |
| 2026-07-23 | 1 | `as never` banned repo-wide (ESLint `no-restricted-syntax`) | It silences all type checking; replaced by typed helpers (`hookFilledCreateData`, tests' `stub<T>`) that keep property-level checking |
| 2026-07-23 | 1 | Lint gate is zero warnings (`--max-warnings=0`) | Warnings that never fail teach everyone to ignore the linter |
| 2026-07-23 | 1 | knip: files/dependencies = error; unused exports = warn (ledgered) | 158 export findings are dominated by god modules Phase 5 splits; failing CI on them day one would freeze the repo |
| 2026-07-23 | 1 | CI on the GitHub mirror (`.github/workflows/ci.yml`) | Pushes go to Codeberg + GitHub; GitHub Actions runs on the mirror with a Postgres 17 service |
| 2026-07-23 | 2 | Immutable TSE aggregates become a committed artifact (script-generated), not build-time computation | Vercel builds must not depend on prod DB content (TSE seeds are local-only); provenance stays explicit like `build:geometries` |
| 2026-07-23 | 2 | Salvador is the only zone-split municipality in the artifact | 2026-07-23 remodel: Camaçari is a single municipality (ZE 170/171 aggregated) |
## Next Actions
- [x] Phase 0 shipped (commit `fe0fb1c`)
- [ ] Phase 1: finish tests `as never` conversion; full verify (lint/typecheck/test/build); commit
- [ ] Phase 2 — caching
- [ ] Phase 3 — loading feedback
- [ ] Phase 4 — client boundary
- [ ] Phase 5 — structure
- [ ] Phase 6 — documentation; register leftovers in TECH-DEBT.md Debt Ledger

54
docs/TECH-DEBT.md Normal file
View file

@ -0,0 +1,54 @@
# Tech Debt
Ledger of known engineering debt. Items large enough to be scheduled work become entries in [roadmap.md](roadmap.md) (linked in the Ref column); this file tracks the full inventory so nothing is lost. Sourced initially from the 2026-07-23 engineering-standards audits.
## Debt Ledger
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
| ---- | -------- | ---- | ---- | ------ | -------- | ------ | --- |
| 158 unused exports reported by knip (warn-level) | god modules, ui kit families | dead code | low | M | P2 | open — drive `exports` rule to error after splits | Phase 5 |
| 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/campaignAccess.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 | open | AGENTS Known Gaps #2 |
| E2E suite thin (5 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 | |
| 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 | planned (Phase 2) | |
| 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 | |
## Smell Inventory
| Smell | Location | Refactoring | Status |
| ----- | -------- | ----------- | ------ |
| God module: RBAC for every collection in one file (964 lines, ~80 exports) | `src/utilities/campaignAccess.ts` | split per domain under `src/utilities/access/` | planned (Phase 5) |
| God module: ~71 exports of electoral math | `src/lib/electionInsights.ts` | split by concern when next touched | ledgered |
| Multi-feature action bag: CRUD + CSV import pipeline + LGPD in one file (722 lines) | `src/app/(campaign)/campanha/actions/supporter.ts` | split CRUD vs import | planned (Phase 5) |
| Clone twins: create/update/staff-assert/unique-conflict skeleton | `actions/organization.ts` ↔ `actions/stateDeputy.ts` | extract shared factory | planned (Phase 5) |
| 13 near-identical `formActions.ts` try/catch→mapError→redirect ladders | `src/app/(campaign)/campanha/(app)/*/formActions.ts` | shared form-action wrapper | planned (Phase 5) |
| Flat 80+-file `src/utilities/` junk drawer; `lib/` vs `utilities/` boundary undocumented | `src/utilities/`, `src/lib/` | document convention + move misfiled modules | planned (Phase 5/6) |
| Praça→Município rename leftovers in identifiers (`PLAZA_MAP_YEARS`, `ACCESSIBLE_PLAZA_IDS_CONTEXT_KEY`, …) | map/list utilities | finish rename | planned (Phase 5) |
| Props→state `useEffect` mirrors | `MunicipalityListExpectedVotesControl`, comboboxes | key-remount or fully controlled | planned (Phase 4) |
| Detail-page RSCs mixing loading + tab UI + forms (300+ lines) | `municipios/[slug]/page.tsx`, `planos/[slug]/page.tsx` | extract tab loaders/sections | ledgered |
## Sprout / Wrap Register
| Host | Sprouted/Wrapped | Notes |
| ---- | ---------------- | ----- |
| (none yet) | | |
## Adopted Conventions
- **Zero lint warnings.** `pnpm lint` runs with `--max-warnings=0`; deliberate placeholders use a `_` prefix (`argsIgnorePattern`).
- **`as never` is banned** (ESLint `no-restricted-syntax`). Production code uses typed helpers (e.g. `hookFilledCreateData` for hook-completed create data); tests use `stub<T>(partial)` which keeps property-level checking. A justified `eslint-disable-next-line` is the only escape hatch.
- **Dead code is deleted, not commented.** knip runs in CI (files/dependencies at error level). After any change: `pnpm exec knip` and remove what died.
- **Collections always declare `access` explicitly.** Payload's default ("any authenticated user") is treated as a bug — see `payloadAdminOnly`.
- **Generated migration signatures are exempt** from unused-args linting (`src/migrations/**` override); never hand-edit migration content for lint cosmetics.
- **Shared consent fixture rows are leased, never owned** in integration tests (`tests/helpers/testDatabaseLease.ts`); deleting them steals from parallel spec files.
## Debt Budget & Broken-Windows Policy
- New warnings/`as never`/unused exports fail or get ledgered the same day — no untracked `// TODO`s.
- Each delivery that runs `/simplify` registers surviving debts here or in `docs/plans/escala-dry-pos-*.md` (existing convention), whichever the item's size warrants.

32
docs/TESTING.md Normal file
View file

@ -0,0 +1,32 @@
# Testing
## Test Strategy
Three layers, all local-database-only (see `local-database` skill; tests refuse any database not ending in `_test`):
- **Unit** (`tests/unit/**/*.unit.spec.ts`, 33+ specs): pure logic; runs with a deliberately invalid `DATABASE_URL` so nothing can touch a database.
- **Integration** (`tests/int/**/*.int.spec.ts`, 42+ specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
- **E2E** (`tests/e2e`, Playwright, 5 specs): smoke-level; most campaign behavior is pinned at the int layer.
CI (`.github/workflows/ci.yml`) runs lint (zero warnings), typecheck, knip, unit, migrate, int against a Postgres 17 service.
## Safety Net Map
Modules the 2026-07 hardening phases touch, and where their behavior is pinned:
| Module | Pinned behaviors | Test files | Gaps |
| ------ | ---------------- | ---------- | ---- |
| `campaignAccess.ts` (RBAC) | per-role municipality/leadership/pledge/supporter/demand/plan scoping; leader lockdown; staff field asymmetry | `campaignMunicipalityAccess`, `campaignVotePledge`, `campaignSupporter`, `campaignUserPhone`, `campaignActionPlan`, `campaignDemandWorkflow`, `collectionAccessLockdown` (int) | no dedicated per-export suite; covered through collection behavior |
| Collection access lockdown | campaign JWT denied on `users`/PII/CMS writes; anonymous read of public content; admin unaffected | `collectionAccessLockdown.int.spec.ts` | REST-transport variant untested (Local API with `overrideAccess: false` is equivalent) |
| `municipalityMapData.ts` | bundle scoping by URL filters + advisor access; `validVotesByYear` incl. 2026 reuse of 2022; null on empty scope | `municipalityMapData.int.spec.ts` | zone breakdown shape |
| `votePledgeData.ts` | `estimated[scenario] ?? declared` aggregation; role scoping; leader denial | `campaignVotePledge.int.spec.ts` | |
| Supporter import (token/bulk) | preview→confirm flow, dedup by phone, consent fail-closed | `campaignSupporter.int.spec.ts` | `supporterImportBulk`/`supporterImportToken` internals only via flow |
| Leader supporter create | linked-municipality scope; out-of-scope rejection; staff rejection | `collectionAccessLockdown.int.spec.ts` | |
| Posts visibility/caching | `isPostVisible` fail-closed; cached listing | (indirect via routes) | no dedicated `posts` spec — backlog |
## Characterization Backlog
- [ ] `src/utilities/posts.ts` — dedicated spec for `isPostVisible` fail-closed matrix and cache wrappers.
- [ ] `actionPlanViewModels.ts` + other `actionPlan*` utilities (~1k lines) — currently untested directly.
- [ ] `campaignDemandData.ts` / `leadershipData.ts` / `organizationData.ts` / `stateDeputyData.ts` list loaders.
- [ ] Election aggregate artifact (Phase 2): snapshot test pinning artifact ↔ `municipalityCatalog` key consistency.

View file

@ -141,6 +141,40 @@ const eslintConfig = [
'local/use-server-async-exports': 'error',
},
},
{
// Underscore-prefixed bindings are deliberate placeholders (unused action
// state, ignored tuple slots); everything else unused is dead code.
files: ['**/*.{js,mjs,jsx,ts,tsx}'],
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
// `as never` silences every type check on the expression (worse than
// `any`). Fix the types instead; a justified eslint-disable comment is
// the only sanctioned escape hatch.
'no-restricted-syntax': [
'error',
{
selector: 'TSAsExpression > TSNeverKeyword',
message:
'Do not cast with `as never` — it disables type checking entirely. Fix the types or use a narrowly-typed helper.',
},
],
},
},
{
// Payload generates migration signatures with unused destructured args.
files: ['src/migrations/**/*.ts'],
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }],
},
},
prettier,
// Override default ignores of eslint-config-next.
globalIgnores([

31
knip.json Normal file
View file

@ -0,0 +1,31 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": [
"src/payload.config.ts",
"src/migrations/index.ts",
"src/app/(payload)/admin/importMap.js",
"scripts/*.mjs",
"eslint-local-rules.mjs"
],
"project": ["src/**/*.{ts,tsx,js}", "scripts/**/*.mjs"],
"ignore": ["src/payload-types.ts", "src/migrations/**"],
"paths": {
"server-only": ["tests/helpers/serverOnly.ts"]
},
"ignoreBinaries": ["playwright"],
"ignoreDependencies": [
"tsx",
"sharp",
"tailwindcss-animate",
"tw-animate-css",
"shadcn",
"eslint-config-next",
"prettier"
],
"rules": {
"exports": "warn",
"types": "warn",
"enumMembers": "warn",
"duplicates": "warn"
}
}

View file

@ -29,6 +29,7 @@
"db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs",
"db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs",
"build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs",
"build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs",
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
"build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
@ -38,7 +39,9 @@
"migrate": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate",
"migrate:create": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate:create",
"migrate:status": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate:status",
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint --cache --cache-location .next/cache/eslint/",
"knip": "knip",
"knip:production": "knip --production",
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint --cache --cache-location .next/cache/eslint/ --max-warnings=0",
"lint:fix": "cross-env NODE_OPTIONS=--no-deprecation eslint --fix",
"payload": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
@ -62,10 +65,6 @@
"@payloadcms/richtext-lexical": "3.82.0",
"@payloadcms/storage-vercel-blob": "3.82.0",
"@payloadcms/ui": "3.82.0",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"class-variance-authority": "^0.7.1",
@ -101,6 +100,7 @@
"@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.21",
"@types/node": "22.19.9",
"@types/pg": "^8.10.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
@ -117,6 +117,8 @@
"eslint-plugin-check-file": "^3.3.1",
"husky": "^9.1.7",
"jsdom": "28.0.0",
"knip": "^6.27.0",
"pg": "^8.22.0",
"postcss": "^8.5.6",
"prettier": "^3.4.2",
"schema-dts": "^2.0.0",
@ -126,7 +128,6 @@
"tsx": "4.21.0",
"typescript": "npm:@typescript/typescript6@6.0.2",
"typescript-7": "npm:typescript@7.0.2",
"typescript-eslint": "^8.64.0",
"vite-tsconfig-paths": "6.0.5",
"vitest": "4.0.18",
"yauzl": "^3.4.0"

File diff suppressed because it is too large Load diff

View file

@ -130,7 +130,7 @@ const buildDemographicsByCode = ({ ageCategoryRows, femaleRows, medianRows }) =>
current.medianAge = Number.isFinite(median) ? median : null
}
for (const [code, record] of byCode.entries()) {
for (const record of byCode.values()) {
record.population = Object.values(record.ageBands).reduce((sum, value) => sum + value, 0)
record.sexShareFemale =
record.population > 0 ? record.femalePopulation / record.population : 0

View file

@ -1,214 +0,0 @@
#!/usr/bin/env node
/**
* One-shot mechanical rename: plaza → municipality across src/ and tests/.
* Excludes frozen historical migration artifacts.
*/
import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs'
import { dirname, join, relative } from 'path'
const ROOT = join(import.meta.dirname, '..')
const EXCLUDE_PATH_FRAGMENTS = [
'src/migrations/data/plazaCatalog20260721.ts',
'src/migrations/20260721_020109_remodel_plazas.ts',
'src/migrations/20260721_020109_remodel_plazas.json',
'src/migrations/20260721_133444_add_plaza_expected_votes.ts',
'src/migrations/20260721_133444_add_plaza_expected_votes.json',
'node_modules/',
'.git/',
'scripts/rename-plaza-to-municipality.mjs',
]
const shouldExclude = (relPath) => EXCLUDE_PATH_FRAGMENTS.some((frag) => relPath.includes(frag))
const renamePathSegment = (segment) =>
segment
.replace(/PlazaUpdate/g, 'MunicipalityUpdate')
.replace(/plazaUpdate/g, 'municipalityUpdate')
.replace(/Plaza/g, 'Municipality')
.replace(/plaza/g, 'municipality')
.replace(/^pracas$/, 'municipios')
function walk(dir, files = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
const rel = relative(ROOT, full)
if (shouldExclude(rel)) continue
const st = statSync(full)
if (st.isDirectory()) {
if (entry === 'node_modules' || entry === '.git') continue
walk(full, files)
} else if (/\.(ts|tsx|js|jsx|json|md|mdc)$/.test(entry)) {
files.push(full)
}
}
return files
}
// Rename directories bottom-up
function collectDirs(dir, dirs = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
const rel = relative(ROOT, full)
if (shouldExclude(rel)) continue
if (statSync(full).isDirectory()) {
collectDirs(full, dirs)
if (/plaza|Plaza|pracas/i.test(entry)) dirs.push(full)
}
}
return dirs
}
const dirRenames = collectDirs(join(ROOT, 'src'))
.concat(collectDirs(join(ROOT, 'tests')))
.sort((a, b) => b.length - a.length)
for (const dir of dirRenames) {
const parent = dirname(dir)
const base = renamePathSegment(dir.split('/').pop())
const target = join(parent, base)
if (dir !== target) {
mkdirSync(dirname(target), { recursive: true })
try {
renameSync(dir, target)
console.log('DIR', relative(ROOT, dir), '->', relative(ROOT, target))
} catch (e) {
// already renamed
}
}
}
// Rename files
const allFiles = walk(join(ROOT, 'src')).concat(walk(join(ROOT, 'tests')))
const fileRenames = allFiles
.filter((f) => /plaza|Plaza|pracas/i.test(f.split('/').pop()))
.sort((a, b) => b.length - a.length)
for (const file of fileRenames) {
const parent = dirname(file)
const base = renamePathSegment(file.split('/').pop())
const target = join(parent, base)
if (file !== target) {
try {
renameSync(file, target)
console.log('FILE', relative(ROOT, file), '->', relative(ROOT, target))
} catch {
/* noop */
}
}
}
const REPLACEMENTS = [
['/campanha/pracas', '/campanha/municipios'],
["collection: 'plazaUpdate'", "collection: 'municipalityUpdate'"],
["collection: 'plaza'", "collection: 'municipality'"],
["relationTo: 'plazaUpdate'", "relationTo: 'municipalityUpdate'"],
["relationTo: 'plaza'", "relationTo: 'municipality'"],
['slug: plazaUpdate', 'slug: municipalityUpdate'],
['slug: plaza', 'slug: municipality'],
["'plazaUpdate'", "'municipalityUpdate'"],
["'plaza'", "'municipality'"],
['PlazaUpdate', 'MunicipalityUpdate'],
['plazaUpdate', 'municipalityUpdate'],
['PlazaEstimateScenario', 'MunicipalityEstimateScenario'],
['plazaEstimateScenario', 'municipalityEstimateScenario'],
['PlazaZoneNeighborhoods', 'MunicipalityZoneNeighborhoods'],
['plazaZoneNeighborhoods', 'municipalityZoneNeighborhoods'],
['plaza-zone-neighborhoods', 'municipality-zone-neighborhoods'],
['plaza-catalog', 'municipality-catalog'],
['plazaStaffFormActions', 'municipalityStaffFormActions'],
['plazaStaffEditMessages', 'municipalityStaffEditMessages'],
['plazaStaffVoteTotal', 'municipalityStaffVoteTotal'],
['StaffPlazaVotes', 'StaffMunicipalityVotes'],
['aggregatePledgesByPlaza', 'aggregatePledgesByMunicipality'],
['loadPlazaListPageBundle', 'loadMunicipalityListPageBundle'],
['loadPlazaMapBundle', 'loadMunicipalityMapBundle'],
['buildPlazaMapBundleFromPlazas', 'buildMunicipalityMapBundleFromMunicipalities'],
['buildPlazaMapBundle', 'buildMunicipalityMapBundle'],
['buildPlazaListWhere', 'buildMunicipalityListWhere'],
['shouldUpdatePlazaSearchUrl', 'shouldUpdateMunicipalitySearchUrl'],
['buildPlazaFiltersKey', 'buildMunicipalityFiltersKey'],
['getAccessiblePlazaIds', 'getAccessibleMunicipalityIds'],
['canAssignPlazaAdvisors', 'canAssignMunicipalityAdvisors'],
['canManagePlazaAdvisors', 'canManageMunicipalityAdvisors'],
['canCreatePlaza', 'canCreateMunicipality'],
['canDeletePlaza', 'canDeleteMunicipality'],
['canReadPlaza', 'canReadMunicipality'],
['canUpdatePlaza', 'canUpdateMunicipality'],
['validatePlazaAdvisors', 'validateMunicipalityAdvisors'],
['plazaCatalogEntriesForCity', 'municipalityCatalogEntriesForCity'],
['getPlazaCatalogEntry', 'getMunicipalityCatalogEntry'],
['plazaCatalog', 'municipalityCatalog'],
['isPlazaSlug', 'isMunicipalitySlug'],
['PlazaCatalogEntry', 'MunicipalityCatalogEntry'],
['PlazaKind', 'MunicipalityKind'],
['ZONE_PLAZA_CITIES', 'ZONE_MUNICIPALITY_CITIES'],
['zonePlazaCitySet', 'zoneMunicipalityCitySet'],
['plazasByIbgeCode', 'municipalitiesByIbgeCode'],
['resolvePlazaMapNavigation', 'resolveMunicipalityMapNavigation'],
['plazaMapNavigation', 'municipalityMapNavigation'],
['plazaRevalidation', 'municipalityRevalidation'],
['plazaSlug', 'municipalitySlug'],
['plazaPageData', 'municipalityPageData'],
['plazaViewModels', 'municipalityViewModels'],
['plazaMapData', 'municipalityMapData'],
['plazaUi', 'municipalityUi'],
['plazaElectoralBaseline', 'municipalityElectoralBaseline'],
['plazaElectionGeography', 'municipalityElectionGeography'],
['plazaCandidateComparison', 'municipalityCandidateComparison'],
['plazaDetailTabUi', 'municipalityDetailTabUi'],
['campaignPlazaAccess', 'campaignMunicipalityAccess'],
['campaignPlazas', 'campaignMunicipalities'],
['campaignPlazaUpdate', 'campaignMunicipalityUpdate'],
['updatePlazaStrategy', 'updateMunicipalityStrategy'],
['PlazaMapBundle', 'MunicipalityMapBundle'],
['plazaMapBundle', 'municipalityMapBundle'],
['PlazaList', 'MunicipalityList'],
['PlazaFilters', 'MunicipalityFilters'],
['PlazaMap', 'MunicipalityMap'],
['PlazaStrategy', 'MunicipalityStrategy'],
['PlazaBaseline', 'MunicipalityBaseline'],
['PlazaCandidate', 'MunicipalityCandidate'],
['PlazaAdvisor', 'MunicipalityAdvisor'],
['PlazaLeadership', 'MunicipalityLeadership'],
['PlazaPledges', 'MunicipalityPledges'],
['PlazaTab', 'MunicipalityTab'],
['PlazaUpdate', 'MunicipalityUpdate'],
['plazas', 'municipalities'],
['Plaza', 'Municipality'],
['plaza', 'municipality'],
]
const contentFiles = walk(join(ROOT, 'src'))
.concat(walk(join(ROOT, 'tests')))
.concat(
[
join(ROOT, 'AGENTS.md'),
join(ROOT, 'PRODUCT.md'),
join(ROOT, 'docs/roadmap.md'),
join(ROOT, '.cursor/rules/projects/nucleos-eleitorais.mdc'),
].filter((f) => {
try {
statSync(f)
return true
} catch {
return false
}
}),
)
for (const file of contentFiles) {
const rel = relative(ROOT, file)
if (shouldExclude(rel)) continue
let content = readFileSync(file, 'utf8')
const original = content
for (const [from, to] of REPLACEMENTS) {
content = content.split(from).join(to)
}
if (content !== original) {
writeFileSync(file, content)
console.log('CONTENT', rel)
}
}
console.log('Done.')

View file

@ -3,7 +3,7 @@
import { redirect } from 'next/navigation'
import { createStateDeputy } from '@/app/(campaign)/campanha/actions/stateDeputy'
import { nullableFormText, optionalFormText, requiredFormText } from '@/lib/formData'
import { optionalFormText, requiredFormText } from '@/lib/formData'
import {
mapCampaignFormActionError,
type CampaignFormActionState,

View file

@ -1,4 +1,3 @@
import config from '@payload-config'
import { ArrowLeftIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'

View file

@ -6,7 +6,6 @@ import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Empty,

View file

@ -13,6 +13,7 @@ import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campa
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
const MAX_ACTION_PLAN_UPDATE_BODY_LENGTH = 4000
const MAX_ACTION_PLAN_RESULT_SUMMARY_LENGTH = 6000
@ -26,7 +27,7 @@ export const createActionPlanRecord = async (
return payload.create({
collection: 'actionPlan',
data: data as never,
data: hookFilledCreateData<'actionPlan'>(data),
depth: 0,
user: actor,
overrideAccess: false,

View file

@ -21,6 +21,7 @@ import {
} from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
export const createCampaignDemandRecord = async (
payload: Payload,
@ -38,7 +39,7 @@ export const createCampaignDemandRecord = async (
// links a leader's own leadership and enforces the initial status.
return payload.create({
collection: 'campaignDemand',
data: data as never,
data: hookFilledCreateData<'campaignDemand'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,

View file

@ -6,6 +6,7 @@ import { municipalityUpdateCreateSchema, type MunicipalityUpdateCreateInput } fr
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
export const createMunicipalityUpdateRecord = async (
payload: Payload,
@ -21,7 +22,7 @@ export const createMunicipalityUpdateRecord = async (
return payload.create({
collection: 'municipalityUpdate',
data: data as never,
data: hookFilledCreateData<'municipalityUpdate'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,

View file

@ -10,6 +10,7 @@ import {
} from '@/lib/schemas/organization'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
const assertStaffActor = (payload: Payload, actor: CampaignUser): Promise<CampaignUser> =>
reloadStaffActor(payload, actor, 'Somente a coordenação e a assessoria gerenciam organizações.')
@ -30,7 +31,7 @@ export const createOrganizationRecord = async (
try {
return await payload.create({
collection: 'organization',
data: data as never,
data: hookFilledCreateData<'organization'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,

View file

@ -10,6 +10,7 @@ import {
} from '@/lib/schemas/stateDeputy'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
const assertStaffActor = (payload: Payload, actor: CampaignUser): Promise<CampaignUser> =>
reloadStaffActor(payload, actor, 'Somente a coordenação e a assessoria gerenciam dobradinhas.')
@ -30,7 +31,7 @@ export const createStateDeputyRecord = async (
try {
return await payload.create({
collection: 'stateDeputy',
data: data as never,
data: hookFilledCreateData<'stateDeputy'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,

View file

@ -19,6 +19,8 @@ fbq('track', 'PageView');
`}
</Script>
<noscript>
{/* Meta's noscript tracking pixel must be a plain 1×1 <img>. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
height="1"

View file

@ -6,12 +6,12 @@ import { NameInput } from '@/components/NameInput'
import { PhoneInput } from '@/components/PhoneInput'
import { StateSelect } from '@/components/StateSelect'
import { Button } from '@/components/ui/button'
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend } from '@/components/ui/field'
import { Field, FieldDescription, FieldGroup } from '@/components/ui/field'
import { Textarea } from '@/components/ui/textarea'
import { WhatsAppFormInput, whatsAppFormSchema } from '@/lib/schemas/whatsapp-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form'
import { useEffect, useTransition } from 'react'
import { useTransition } from 'react'
import { submitWhatsapp } from '@/app/(frontend)/actions/submitWhatsapp'
interface WhatsappFormProps {

View file

@ -386,6 +386,9 @@ export const BahiaMap = ({
useEffect(() => {
if (!interactiveKey || pathByKeyRef.current.size === 0) return
restyleAllPaths()
// Restyle helpers only read refs and are recreated per render — depending
// on them would re-run this effect on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps -- interactiveKey-only restyle
}, [interactiveKey])
useEffect(() => {
@ -425,6 +428,9 @@ export const BahiaMap = ({
if (selectedKey) {
restyleFeature(selectedKey)
}
// Restyle helpers only read refs and are recreated per render — depending
// on them would re-run this effect on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedKey-only O(2) restyle
}, [selectedKey])
return (

View file

@ -1,18 +0,0 @@
'use client'
import dynamic from 'next/dynamic'
import type { ComponentType } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
export const createCampaignClientDynamic = <P extends object>(
importModule: () => Promise<Record<string, ComponentType<P>>>,
exportName: string,
skeletonHeightClassName: string,
) =>
dynamic(() => importModule().then((module) => module[exportName]), {
ssr: false,
loading: () => (
<Skeleton className={`${skeletonHeightClassName} w-full rounded-lg`} aria-hidden="true" />
),
})

View file

@ -1,59 +0,0 @@
'use client'
import { AlertCircleIcon } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Spinner } from '@/components/ui/Spinner'
export const LazyDialogModuleFallback = ({
description,
loadingLabel,
onRetry,
status,
title,
}: {
description: string
loadingLabel: string
onRetry: () => void
status: 'idle' | 'loading' | 'error'
title: string
}) => (
<DialogContent showCloseButton={status !== 'error'}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{status === 'error' ? (
<>
<Alert variant="destructive">
<AlertCircleIcon aria-hidden="true" />
<AlertTitle>Conteúdo indisponível</AlertTitle>
<AlertDescription>Não foi possível carregar este conteúdo.</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
<Button type="button" onClick={onRetry}>
Tentar novamente
</Button>
</DialogFooter>
</>
) : (
<div className="flex min-h-24 items-center justify-center" aria-label={loadingLabel}>
<Spinner aria-hidden="true" />
</div>
)}
</DialogContent>
)

View file

@ -156,7 +156,6 @@ export const MunicipalityList = ({
</TableHeader>
<TableBody>
{municipalities.map((municipality) => {
const names = advisorNames(municipality)
const hasAdvisor = municipality.advisorIDs.length > 0
return (

View file

@ -13,15 +13,19 @@ export const RecentVisitTracker = ({
}: {
entry: Omit<RecentVisitEntry, 'visitedAt'>
}) => {
// Primitive dependencies: the effect must re-run on visit identity changes,
// not on parent re-renders handing down a fresh `entry` object.
const { href, label, kind } = entry
useEffect(() => {
const timer = window.setTimeout(() => {
recordRecentVisit({ ...entry, visitedAt: Date.now() })
recordRecentVisit({ href, label, kind, visitedAt: Date.now() })
}, RECORD_DWELL_MS)
return () => {
window.clearTimeout(timer)
}
}, [entry.href, entry.label, entry.kind])
}, [href, label, kind])
return null
}

View file

@ -1,32 +0,0 @@
'use client'
import { useCallback, useState } from 'react'
import { RecentlyVisitedCard } from '@/components/campaign/RecentlyVisitedCard'
import { UpcomingActionPlansCard } from '@/components/campaign/UpcomingActionPlansCard'
import { cn } from '@/lib/utils'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import { listRecentVisits } from '@/utilities/recentVisits'
type RecentlyVisitedProps = {
now: Date
upcomingPlans: ActionPlanUpcomingPreviewRecord[]
}
export const RecentlyVisited = ({ now, upcomingPlans }: RecentlyVisitedProps) => {
const [hasRecent, setHasRecent] = useState(() => listRecentVisits().length > 0)
const handleVisitCountChange = useCallback((count: number) => {
setHasRecent(count > 0)
}, [])
return (
<div className={cn('grid gap-6', hasRecent ? 'lg:grid-cols-2 lg:items-start' : undefined)}>
<RecentlyVisitedCard
now={now}
clearControl="labeled"
onVisitCountChange={handleVisitCountChange}
/>
<UpcomingActionPlansCard plans={upcomingPlans} />
</div>
)
}

View file

@ -1,60 +0,0 @@
import { ArrowRightIcon } from 'lucide-react'
import Link from 'next/link'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { cn } from '@/lib/utils'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
export const UpcomingActionPlansCard = ({
plans,
className,
}: {
plans: ActionPlanUpcomingPreviewRecord[]
className?: string
}) => (
<Card className={cn('flex h-full flex-col', className)}>
<CardHeader>
<CardTitle>Próximos eventos</CardTitle>
<CardAction>
<Button asChild variant="ghost" size="sm" className="min-h-11">
<Link href="/campanha/planos">
Ver todos
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</Button>
</CardAction>
</CardHeader>
<CardContent className="flex-1">
{plans.length ? (
<ul className="m-0 flex list-none flex-col gap-2 p-0">
{plans.map((plan) => (
<li
key={plan.id}
className="flex flex-col gap-1 border-b pb-2 last:border-b-0 last:pb-0"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{actionPlanKindLabels[plan.kind]}</Badge>
<span className="text-sm text-muted-foreground">
{formatBahiaDateTimeLabel(plan.startAt)}
{plan.municipalityName ? ` · ${plan.municipalityName}` : ''}
</span>
</div>
<Link
href={`/campanha/planos/${plan.slug}`}
className="text-sm text-primary underline-offset-4 hover:underline"
>
{plan.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhum evento agendado</p>
)}
</CardContent>
</Card>
)

View file

@ -1,67 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
type LazyDialogModule<T> = { default: T }
type LazyDialogModuleState<T> =
| { status: 'idle'; component: null }
| { status: 'loading'; component: null }
| { status: 'ready'; component: T }
| { status: 'error'; component: null }
export const useLazyDialogModule = <T,>(
loadModule: () => Promise<LazyDialogModule<T>>,
) => {
const [state, setState] = useState<LazyDialogModuleState<T>>({
status: 'idle',
component: null,
})
const statusRef = useRef<LazyDialogModuleState<T>['status']>('idle')
const attemptRef = useRef(0)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
attemptRef.current += 1
}
}, [])
const updateState = useCallback((nextState: LazyDialogModuleState<T>) => {
statusRef.current = nextState.status
setState(nextState)
}, [])
const load = useCallback(() => {
if (statusRef.current === 'loading' || statusRef.current === 'ready') return
const attempt = ++attemptRef.current
updateState({ status: 'loading', component: null })
void Promise.resolve()
.then(loadModule)
.then(({ default: component }) => {
if (!mountedRef.current || attempt !== attemptRef.current) return
updateState({ status: 'ready', component })
})
.catch(() => {
if (!mountedRef.current || attempt !== attemptRef.current) return
updateState({ status: 'error', component: null })
})
}, [loadModule, updateState])
const resetAfterClose = useCallback(() => {
if (statusRef.current !== 'error') return
attemptRef.current += 1
updateState({ status: 'idle', component: null })
}, [updateState])
return {
component: state.component,
load,
resetAfterClose,
status: state.status,
}
}

View file

@ -1,234 +0,0 @@
'use client'
import * as React from 'react'
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
import { CheckIcon, ChevronRightIcon } from 'lucide-react'
const DropdownMenu = ({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) => (
<DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
)
const DropdownMenuPortal = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) => (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
const DropdownMenuTrigger = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) => (
<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
)
const DropdownMenuContent = ({
className,
align = 'start',
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn(
'z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
const DropdownMenuGroup = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) => (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
const DropdownMenuItem = ({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) => (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
)
const DropdownMenuCheckboxItem = ({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) => (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
const DropdownMenuRadioGroup = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) => (
<DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
)
const DropdownMenuRadioItem = ({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) => (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
const DropdownMenuLabel = ({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) => (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
className,
)}
{...props}
/>
)
const DropdownMenuSeparator = ({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) => (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
const DropdownMenuShortcut = ({ className, ...props }: React.ComponentProps<'span'>) => (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
className,
)}
{...props}
/>
)
const DropdownMenuSub = ({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) => (
<DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
)
const DropdownMenuSubTrigger = ({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) => (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
const DropdownMenuSubContent = ({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) => (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
)
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -1,78 +0,0 @@
'use client'
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Tabs as TabsPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
const Tabs = ({
className,
orientation = 'horizontal',
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) => (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn('group/tabs flex gap-2 data-horizontal:flex-col', className)}
{...props}
/>
)
const tabsListVariants = cva(
'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:max-w-full group-data-horizontal/tabs:justify-start group-data-horizontal/tabs:overflow-x-auto group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none',
{
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
variant: 'default',
},
},
)
const TabsList = ({
className,
variant = 'default',
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) => (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
const TabsTrigger = ({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) => (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex min-h-11 flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
className,
)}
{...props}
/>
)
const TabsContent = ({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) => (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 text-sm outline-none', className)}
{...props}
/>
)
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View file

@ -1,185 +0,0 @@
'use client'
import * as React from 'react'
import { Select as SelectPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react'
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn('scroll-my-1 p-1', className)}
{...props}
/>
)
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-[var(--field-border)] bg-[var(--field-background)] py-2 pr-2 pl-2.5 text-sm whitespace-nowrap text-[var(--field-foreground)] transition-colors outline-none select-none focus-visible:border-primary focus-visible:ring-3 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-[var(--field-placeholder)] data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-[var(--field-icon)]" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = 'item-aligned',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === 'item-aligned'}
className={cn(
'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
'data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)',
position === 'popper' && '',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('px-1.5 py-1 text-xs text-muted-foreground', className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -1,98 +0,0 @@
import type { Payload, Where } from 'payload'
import type { ActionPlan, CampaignUser } from '@/payload-types'
import { relationshipId } from '@/utilities/relationship'
export const actionPlanUpcomingPreviewLimit = 3
const upcomingActionPlanSelect = {
title: true,
slug: true,
kind: true,
startAt: true,
municipality: true,
} as const
export type ActionPlanUpcomingPreviewRecord = {
id: number
slug: string
title: string
kind: ActionPlan['kind']
startAt: string
municipalityName: string | null
}
export type ActionPlanUpcomingPreviewFilters = {
municipality?: number
}
const loadMunicipalityNamesById = async (
payload: Pick<Payload, 'find'>,
municipalityIds: number[],
): Promise<Map<number, string>> => {
if (municipalityIds.length === 0) return new Map()
// Display-name lookup: row access on the plans already gated visibility.
const result = await payload.find({
collection: 'municipality',
where: { id: { in: municipalityIds } },
depth: 0,
pagination: false,
select: { name: true },
overrideAccess: true,
})
return new Map(result.docs.map((municipality) => [municipality.id, municipality.name]))
}
export const loadUpcomingActionPlansPreview = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
now: Date,
options: {
limit?: number
filters?: ActionPlanUpcomingPreviewFilters
} = {},
): Promise<ActionPlanUpcomingPreviewRecord[]> => {
const limit = options.limit ?? actionPlanUpcomingPreviewLimit
const filters: Where[] = [
{ status: { in: ['planejado', 'confirmado'] } },
{ startAt: { greater_than_equal: now.toISOString() } },
]
if (options.filters?.municipality) {
filters.push({ municipality: { equals: options.filters.municipality } })
}
const result = await payload.find({
collection: 'actionPlan',
where: { and: filters },
depth: 0,
limit,
page: 1,
sort: 'startAt',
select: upcomingActionPlanSelect,
user,
overrideAccess: false,
})
const municipalityIds = [
...new Set(
result.docs
.map((plan) => relationshipId(plan.municipality))
.filter((id): id is number => id !== null),
),
]
const municipalityNamesById = await loadMunicipalityNamesById(payload, municipalityIds)
return result.docs.map((plan) => {
const municipalityId = relationshipId(plan.municipality)
return {
id: plan.id,
slug: plan.slug,
title: plan.title,
kind: plan.kind,
startAt: plan.startAt as string,
municipalityName: municipalityId ? (municipalityNamesById.get(municipalityId) ?? null) : null,
}
})
}

View file

@ -1,4 +1,4 @@
import { and, eq } from '@payloadcms/db-postgres/drizzle'
import { and, eq, type AnyColumn } from '@payloadcms/db-postgres/drizzle'
import type { Payload } from 'payload'
import type {
@ -57,11 +57,21 @@ type PayloadDbDrizzle = {
}
}
/** Payload's generated Drizzle tables type columns as `unknown` — narrow with a runtime check. */
const scopeColumn = (
table: Record<string, unknown>,
name: 'year' | 'office' | 'turn',
): AnyColumn => {
const column = table[name]
if (column == null) throw new Error(`Election table is missing the "${name}" column.`)
return column as AnyColumn
}
const scopeWhere = (table: Record<string, unknown>, scope: ElectionImportScope) =>
and(
eq(table.year as never, scope.year),
eq(table.office as never, scope.office),
eq(table.turn as never, scope.turn),
eq(scopeColumn(table, 'year'), scope.year),
eq(scopeColumn(table, 'office'), scope.office),
eq(scopeColumn(table, 'turn'), scope.turn),
)
const deleteScope = async (

View file

@ -1,58 +0,0 @@
import 'server-only'
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
import { BASELINE_TICKET_2022, ELECTION_YEAR_2022 } from '@/lib/electionResults'
import { assertCanReadElectionData, type ElectionDataReader } from '@/utilities/campaignAccess'
import { drizzleResultRows, requirePostgresDrizzle } from '@/utilities/drizzleBulk'
import { type MunicipalityElectionGeography, zonesByCityCode } from '@/utilities/municipalityElectionGeography'
export type FederalCandidateTotal = {
candidateNumber: number
name: string
party: string
votes: number
}
export const loadFederalCandidateTotalsAggregated = async (
payload: Pick<Payload, 'db'>,
user: ElectionDataReader,
geography: MunicipalityElectionGeography,
): Promise<FederalCandidateTotal[]> => {
assertCanReadElectionData(user)
const cityClauses = [...zonesByCityCode(geography).entries()].map(
([cityCode, zones]) =>
sql`("election_candidate_vote"."city_code" = ${cityCode} AND "election_candidate_vote"."zone_number" IN (${sql.join(
zones.map((zone) => sql`${zone}`),
sql`, `,
)}))`,
)
const drizzle = requirePostgresDrizzle(payload, 'agregação federal')
const result = await drizzle.execute(sql`
SELECT
"election_candidate_vote"."candidate_number" AS "candidate_number",
MAX("election_candidate_vote"."candidate_name") AS "candidate_name",
COALESCE(MAX("election_candidate_vote"."party"), '') AS "party",
SUM("election_candidate_vote"."votes")::int AS "votes"
FROM "election_candidate_vote"
WHERE
"election_candidate_vote"."year" = ${ELECTION_YEAR_2022}
AND "election_candidate_vote"."state" = 'BA'
AND "election_candidate_vote"."office" = ${BASELINE_TICKET_2022.candidate.office}
AND "election_candidate_vote"."turn" = '1'
AND "election_candidate_vote"."vote_type" = 'nominal'
AND (${sql.join(cityClauses, sql` OR `)})
GROUP BY "election_candidate_vote"."candidate_number"
ORDER BY "votes" DESC, "election_candidate_vote"."candidate_number" ASC
`)
return drizzleResultRows(result).map((row) => ({
candidateNumber: Number(row.candidate_number),
name: String(row.candidate_name ?? ''),
party: String(row.party),
votes: Number(row.votes ?? 0),
}))
}

View file

@ -0,0 +1,13 @@
import type { CollectionSlug, RequiredDataFromCollectionSlug } from 'payload'
/**
* Types zod-validated create input as a collection's full create data when the
* remaining required fields (canonical slug, authorship, workflow status) are
* filled by the collection's own beforeValidate/beforeChange hooks.
*
* Unlike an `as never` cast, property-level type checking is preserved for
* every field the caller does provide — only completeness is waived.
*/
export const hookFilledCreateData = <Slug extends CollectionSlug>(
data: Partial<RequiredDataFromCollectionSlug<Slug>>,
): RequiredDataFromCollectionSlug<Slug> => data as RequiredDataFromCollectionSlug<Slug>

View file

@ -1,25 +0,0 @@
export class TseZoneParseError extends Error {}
export const sortedUniqueZoneNumbers = (values: readonly number[]): number[] =>
[...new Set(values)].sort((left, right) => left - right)
export const parseTseZoneNumbers = (value: string): number[] => {
if (!/^[\d,\s]*$/.test(value)) {
throw new TseZoneParseError('Use apenas números, vírgulas e espaços nas Zonas TSE.')
}
return sortedUniqueZoneNumbers(
value
.split(/[,\s]+/)
.filter(Boolean)
.map((token) => {
const zoneNumber = Number(token)
if (!Number.isInteger(zoneNumber) || zoneNumber < 1 || zoneNumber > 999) {
throw new TseZoneParseError(
`Zona TSE inválida: "${token}". Use números de 1 a 999.`,
)
}
return zoneNumber
}),
)
}

View file

@ -60,7 +60,7 @@ class CampaignE2EOwnership {
get: (_target, property) => {
if (property === 'create') {
return async (args: Parameters<Payload['create']>[0]) => {
const document = await rootPayload.create(args as never)
const document = await rootPayload.create(args)
if (this.isOwnedCollection(args.collection) && typeof document.id === 'number') {
this.own(args.collection, document.id)
}

View file

@ -308,7 +308,7 @@ export class CampaignFixtures {
builderCounter += 1
this.runID = `${processRunID}-${builderCounter}`
const trackedCreate = async (args: Parameters<Payload['create']>[0]) => {
const document = await rootPayload.create(args as never)
const document = await rootPayload.create(args)
const collection = args.collection
const isLeasedConsent =
collection === 'consent' &&
@ -609,7 +609,7 @@ export class CampaignFixtures {
contact: relationshipID(input.contact),
municipalities: input.municipalities.map(relationshipID),
...(input.organizations
? { organizations: input.organizations.map((value) => relationshipID(value as never)) }
? { organizations: input.organizations.map((value) => relationshipID(value)) }
: {}),
...(input.user ? { user: relationshipID(input.user) } : {}),
...(input.createdBy ? { createdBy: relationshipID(input.createdBy) } : {}),
@ -917,7 +917,7 @@ export class CampaignFixtures {
collection,
where: { id: { in: ids } },
depth: 0,
req: req as never,
req,
})
const errors = bulkErrors(result)
if (errors.length > 0) {
@ -943,7 +943,7 @@ export class CampaignFixtures {
lastUpdateAt: null,
},
depth: 0,
req: req as never,
req,
})
}
}

7
tests/helpers/stub.ts Normal file
View file

@ -0,0 +1,7 @@
/**
* Types a partial test double as the full contract it stands in for.
*
* Unlike casting through `never`, the keys the test DOES provide keep full
* property-level type checking against `T` — only completeness is waived.
*/
export const stub = <T>(partial: Partial<T>): T => partial as T

View file

@ -1,6 +1,11 @@
// @vitest-environment node
import { getPayload, type Payload } from 'payload'
import {
getPayload,
type Payload,
type PayloadRequest,
type RequiredDataFromCollectionSlug,
} from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { actionPlanCreateSchema, actionPlanUpdateSchema } from '@/lib/schemas/actionPlan'
@ -13,6 +18,10 @@ import {
} from '@/utilities/campaignAccess'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
/** Create data with server-derived fields (e.g. `slug`) intentionally omitted. */
type ActionPlanCreateData = RequiredDataFromCollectionSlug<'actionPlan'>
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -88,13 +97,13 @@ describe('action plan domain', () => {
})
const createAsCoordinator = await canCreateActionPlan({
req: { user: coordinator, payload, context: {} } as never,
req: stub<PayloadRequest>({ user: coordinator, payload, context: {} }),
})
const createAsAdvisor = await canCreateActionPlan({
req: { user: advisor, payload, context: {} } as never,
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
})
const createAsLeader = await canCreateActionPlan({
req: { user: leaderAccount, payload, context: {} } as never,
req: stub<PayloadRequest>({ user: leaderAccount, payload, context: {} }),
})
expect(createAsCoordinator).toBe(true)
expect(createAsAdvisor).toBe(true)
@ -102,49 +111,49 @@ describe('action plan domain', () => {
const plan = await payload.create({
collection: 'actionPlan',
data: {
data: stub<ActionPlanCreateData>({
...validPlanInput(municipality.id),
advisors: [advisor.id],
leadership: leadership.id,
responsible: contact.id,
createdBy: coordinator.id,
} as never,
}),
overrideAccess: true,
})
fixtures.own('actionPlan', plan.id)
const coordinatorRead = await canReadActionPlan({
req: { user: coordinator, payload, context: {} } as never,
req: stub<PayloadRequest>({ user: coordinator, payload, context: {} }),
})
expect(coordinatorRead).toBe(true)
const advisorRead = await canReadActionPlan({
req: { user: advisor, payload, context: {} } as never,
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
})
expect(advisorRead).toEqual({
or: [{ advisors: { contains: advisor.id } }, { municipality: { in: [municipality.id] } }],
})
const otherAdvisorRead = await canReadActionPlan({
req: { user: otherAdvisor, payload, context: {} } as never,
req: stub<PayloadRequest>({ user: otherAdvisor, payload, context: {} }),
})
expect(otherAdvisorRead).toEqual({
or: [{ advisors: { contains: otherAdvisor.id } }, { municipality: { in: [] } }],
})
const leaderRead = await canReadActionPlan({
req: { user: leaderAccount, payload, context: {} } as never,
req: stub<PayloadRequest>({ user: leaderAccount, payload, context: {} }),
})
expect(leaderRead).toBe(false)
const leadershipIds = await getAccessibleLeadershipIds(
{ user: leaderAccount, payload, context: {} } as never,
stub<PayloadRequest>({ user: leaderAccount, payload, context: {} }),
leaderAccount,
)
expect(leadershipIds).toEqual([])
const advisorUpdate = await canUpdateActionPlan({
req: { user: advisor, payload, context: {} } as never,
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
})
expect(advisorUpdate).toEqual({
or: [{ advisors: { contains: advisor.id } }, { municipality: { in: [municipality.id] } }],
@ -187,10 +196,10 @@ describe('action plan domain', () => {
const plan = await payload.create({
collection: 'actionPlan',
data: {
data: stub<ActionPlanCreateData>({
...validPlanInput(municipality.id),
title: fixtures.value('Plano do assessor'),
} as never,
}),
user: advisor,
overrideAccess: false,
})
@ -213,12 +222,12 @@ describe('action plan domain', () => {
await expect(
payload.create({
collection: 'actionPlan',
data: {
data: stub<ActionPlanCreateData>({
...validPlanInput(municipality.id),
title: fixtures.value('Plano sem data'),
startAt: null,
createdBy: coordinator.id,
} as never,
}),
overrideAccess: true,
}),
).rejects.toThrow('Informe a data e horário de início ao planejar ou confirmar o plano.')
@ -227,13 +236,13 @@ describe('action plan domain', () => {
await expect(
payload.create({
collection: 'actionPlan',
data: {
data: stub<ActionPlanCreateData>({
...validPlanInput(municipality.id),
title: fixtures.value('Plano com término invertido'),
startAt: start.toISOString(),
endAt: new Date(start.getTime() - 3_600_000).toISOString(),
createdBy: coordinator.id,
} as never,
}),
overrideAccess: true,
}),
).rejects.toThrow('O horário de término deve ser posterior ao de início.')
@ -255,13 +264,13 @@ describe('action plan domain', () => {
const plan = await payload.create({
collection: 'actionPlan',
data: {
data: stub<ActionPlanCreateData>({
...validPlanInput(municipality.id),
title: fixtures.value('Plano Tarefas'),
advisors: [advisor.id],
leadership: leadership.id,
tasks: [{ title: 'Levar faixas', done: false }],
} as never,
}),
overrideAccess: true,
})
fixtures.own('actionPlan', plan.id)
@ -272,7 +281,7 @@ describe('action plan domain', () => {
id: plan.id,
data: {
tasks: [{ title: 'Levar faixas', done: true }],
} as never,
},
user: leaderAccount,
overrideAccess: false,
}),

View file

@ -2,7 +2,7 @@
import { readFileSync } from 'node:fs'
import type { Payload, PayloadRequest } from 'payload'
import type { Payload, PayloadRequest, RequiredDataFromCollectionSlug } from 'payload'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { assignMunicipalityAdvisorsRecord } from '@/app/(campaign)/campanha/actions/municipality'
@ -20,6 +20,10 @@ import {
import { getPayload } from 'payload'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
/** Create data with `role` intentionally omitted so the collection default applies. */
type CampaignUserCreateData = RequiredDataFromCollectionSlug<'campaignUser'>
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -52,11 +56,11 @@ describe('campaign authentication foundation', () => {
const username = campaignFixtures().phone()
const user = await payload.create({
collection: 'campaignUser',
data: {
data: stub<CampaignUserCreateData>({
name: 'Liderança padrão',
username,
password: campaignFixtures().value('password'),
} as never,
}),
})
expect(user.role).toBe('leader')
@ -267,11 +271,11 @@ describe('campaign authentication foundation', () => {
const username = campaignFixtures().phone()
const target = await payload.create({
collection: 'campaignUser',
data: {
data: stub<CampaignUserCreateData>({
name: 'Liderança administrada',
username,
password: campaignFixtures().value('password'),
} as never,
}),
})
const visibleTarget = await payload.findByID({
@ -383,11 +387,11 @@ describe('campaign authentication foundation', () => {
await expect(
payload.create({
collection: 'campaignUser',
data: {
data: stub<CampaignUserCreateData>({
name: 'Criação indevida',
email: `${campaignFixtures().value('denied')}@example.com`,
password: campaignFixtures().value('password'),
} as never,
}),
user: actor,
overrideAccess: false,
}),

View file

@ -2,20 +2,19 @@
import { createRequire } from 'node:module'
import { sql } from '@payloadcms/db-postgres'
import { sql, type MigrateUpArgs } from '@payloadcms/db-postgres'
import { drizzle } from '@payloadcms/db-postgres/drizzle/node-postgres'
import { getPayload, type Payload } from 'payload'
import type { Pool as PgPool } from 'pg'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { migrations } from '@/migrations'
import { withCampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
const require = createRequire(import.meta.resolve('@payloadcms/db-postgres'))
const { Pool } = require('pg') as {
Pool: new (options: { connectionString: string }) => {
end: () => Promise<void>
query: (statement: string) => Promise<unknown>
}
Pool: new (options: { connectionString: string }) => PgPool
}
const testDatabaseUrl =
@ -113,9 +112,9 @@ beforeAll(async () => {
await adminPool.end()
const migrationPool = new Pool({ connectionString: databaseUrl })
const database = drizzle(migrationPool as never)
const database = drizzle<Record<string, unknown>>(migrationPool)
for (const migration of migrations) {
await migration.up({ db: database } as never)
await migration.up(stub<MigrateUpArgs>({ db: database }))
}
await migrationPool.end()

View file

@ -922,7 +922,10 @@ describe('campaign invite domain', () => {
const actions = await import('@/app/(campaign)/campanha/actions/invite')
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const { contact, leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
const { contact, leadership } = await createInviteLeadershipGraph(
coordinator,
municipality.id,
)
const invite = await createInviteThroughAction(coordinator, {
leadership: leadership.id,
kind: 'autopreenchimento',
@ -983,7 +986,10 @@ describe('campaign invite domain', () => {
const actions = await import('@/app/(campaign)/campanha/actions/invite')
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const { contact, leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
const { contact, leadership } = await createInviteLeadershipGraph(
coordinator,
municipality.id,
)
const invite = await createInviteThroughAction(coordinator, {
leadership: leadership.id,
kind: 'autopreenchimento',
@ -1318,7 +1324,10 @@ describe('campaign invite domain', () => {
const actions = await import('@/app/(campaign)/campanha/actions/invite')
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const { contact, leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
const { contact, leadership } = await createInviteLeadershipGraph(
coordinator,
municipality.id,
)
const invite = await createInviteThroughAction(coordinator, {
leadership: leadership.id,
kind: 'login',
@ -1370,7 +1379,10 @@ describe('campaign invite domain', () => {
})
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const { contact, leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
const { contact, leadership } = await createInviteLeadershipGraph(
coordinator,
municipality.id,
)
const account = await payload.create({
collection: 'campaignUser',
data: {
@ -1425,7 +1437,10 @@ describe('campaign invite domain', () => {
const { getCampaignInvitePageData } = await import('@/utilities/campaignInvitePageData')
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const { contact, leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
const { contact, leadership } = await createInviteLeadershipGraph(
coordinator,
municipality.id,
)
const firstInvite = await createInviteThroughAction(coordinator, {
leadership: leadership.id,
kind: 'autopreenchimento',
@ -1471,7 +1486,7 @@ describe('campaign invite domain', () => {
version: 1,
},
},
} as never,
},
})
const secondInvite = await createInviteThroughAction(coordinator, {
leadership: leadership.id,
@ -1514,7 +1529,10 @@ describe('campaign invite domain', () => {
const records = await import('@/app/(campaign)/campanha/actions/invite')
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const { contact, leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
const { contact, leadership } = await createInviteLeadershipGraph(
coordinator,
municipality.id,
)
const victim = await payload.create({
collection: 'campaignUser',
data: {
@ -1602,7 +1620,10 @@ describe('campaign invite domain', () => {
const records = await import('@/app/(campaign)/campanha/actions/invite')
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const { contact, leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
const { contact, leadership } = await createInviteLeadershipGraph(
coordinator,
municipality.id,
)
const invite = await createInviteThroughAction(coordinator, {
leadership: leadership.id,
kind: 'autopreenchimento',
@ -1654,7 +1675,10 @@ describe('campaign invite domain', () => {
const actions = await import('@/app/(campaign)/campanha/actions/invite')
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const { contact, leadership } = await createInviteLeadershipGraph(coordinator, municipality.id)
const { contact, leadership } = await createInviteLeadershipGraph(
coordinator,
municipality.id,
)
const account = await payload.create({
collection: 'campaignUser',
data: {

View file

@ -66,7 +66,7 @@ describe('campaign leadership domain', () => {
createdBy: 999,
user: 999,
consent: 999,
} as never)
})
expect(parsed.phone).toBe('71999990000')
expect(parsed.municipalities).toEqual([1, 2])
@ -81,7 +81,7 @@ describe('campaign leadership domain', () => {
contact: 999,
user: 999,
createdBy: 999,
} as never)
})
expect(update).toEqual({ id: 1 })
})
@ -188,7 +188,7 @@ describe('campaign leadership domain', () => {
markFirstRead = resolve
})
const findSpy = vi.spyOn(payload, 'find').mockImplementation(async (args) => {
const result = await originalFind(args as never)
const result = await originalFind(args)
if (
args.collection === 'contact' &&
'where' in args &&
@ -200,7 +200,7 @@ describe('campaign leadership domain', () => {
await firstReadGate
}
}
return result as never
return result
})
const first = createLeadershipRecord(payload, coordinator, {
@ -461,7 +461,7 @@ describe('campaign leadership domain', () => {
if (args.collection === 'leadership') {
throw new Error('falha forçada após contato')
}
return originalCreate(args as never)
return originalCreate(args)
})
await expect(

View file

@ -2,29 +2,28 @@
import { createRequire } from 'node:module'
import { sql } from '@payloadcms/db-postgres'
import { sql, type MigrateDownArgs, type MigrateUpArgs } from '@payloadcms/db-postgres'
import { drizzle } from '@payloadcms/db-postgres/drizzle/node-postgres'
import type { Pool as PgPool } from 'pg'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { migrations } from '@/migrations'
import { stub } from '../helpers/stub'
const require = createRequire(import.meta.resolve('@payloadcms/db-postgres'))
const { Pool } = require('pg') as {
Pool: new (options: { connectionString: string }) => {
connect: () => Promise<{ query: (statement: string) => Promise<unknown>; release: () => void }>
end: () => Promise<void>
query: (statement: string) => Promise<{ rows: Array<Record<string, unknown>> }>
}
Pool: new (options: { connectionString: string }) => PgPool
}
const testDatabaseUrl =
process.env.DATABASE_URL ?? 'postgresql://teqo:teqo@localhost:5432/teqo_test'
const adminUrl = testDatabaseUrl.replace(/\/[^/]+$/, '/postgres')
const consolidatedName = '20260718_010733_consolidate_campaign_schema'
type MigrationDatabase = MigrateUpArgs['db']
let databaseSequence = 0
let databaseName = ''
let databasePool: InstanceType<typeof Pool> | undefined
let database: ReturnType<typeof drizzle> | undefined
let database: MigrationDatabase | undefined
const quoteIdentifier = (identifier: string): string => {
if (!/^[a-z0-9_]+$/.test(identifier)) throw new Error(`Unsafe database identifier: ${identifier}`)
@ -77,10 +76,10 @@ beforeEach(async () => {
databasePool = new Pool({
connectionString: testDatabaseUrl.replace(/\/[^/]+$/, `/${databaseName}`),
})
database = drizzle(databasePool as never)
database = drizzle<Record<string, unknown>>(databasePool)
for (const migration of migrations) {
await migration.up({ db: database } as never)
await migration.up(stub<MigrateUpArgs>({ db: database }))
}
})
@ -139,7 +138,7 @@ describe('campaign migration existing-schema reconciliation', () => {
await expect(
database.transaction(async (transaction) => {
await consolidated.up({ db: transaction } as never)
await consolidated.up(stub<MigrateUpArgs>({ db: transaction }))
await transaction.execute(sql`
INSERT INTO "payload_migrations" ("name", "batch", "updated_at", "created_at")
VALUES (${consolidatedName}, 999, now(), now())
@ -176,7 +175,7 @@ describe('campaign migration existing-schema reconciliation', () => {
`)
const checksumBefore = await schemaChecksum()
await expect(consolidated.down({ db: database } as never)).rejects.toThrow(
await expect(consolidated.down(stub<MigrateDownArgs>({ db: database }))).rejects.toThrow(
'Refusing to roll back consolidated campaign schema',
)

View file

@ -1,6 +1,6 @@
// @vitest-environment node
import type { Payload } from 'payload'
import type { Payload, PayloadRequest } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
@ -15,6 +15,7 @@ import config from '@/payload.config'
import { getAccessibleMunicipalityIds } from '@/utilities/campaignAccess'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -117,7 +118,7 @@ describe('municipality catalog seed and per-role access', () => {
})
const visible = await getAccessibleMunicipalityIds(
{ payload, user: account, context: {} } as never,
stub<PayloadRequest>({ payload, user: account, context: {} }),
account,
)
expect(visible?.sort((a, b) => a - b)).toEqual(
@ -145,7 +146,7 @@ describe('municipality catalog seed and per-role access', () => {
})
const scope = await getAccessibleMunicipalityIds(
{ payload, user: account, context: {} } as never,
stub<PayloadRequest>({ payload, user: account, context: {} }),
account,
)
expect(scope).toEqual([])
@ -259,7 +260,7 @@ describe('municipality catalog seed and per-role access', () => {
).toEqual([advisor.id])
const scope = await getAccessibleMunicipalityIds(
{ payload, user: advisor, context: {} } as never,
stub<PayloadRequest>({ payload, user: advisor, context: {} }),
advisor,
)
expect(scope).toEqual([municipality.id])

View file

@ -1,6 +1,11 @@
// @vitest-environment node
import { getPayload, type Payload } from 'payload'
import {
getPayload,
type Payload,
type PayloadRequest,
type RequiredDataFromCollectionSlug,
} from 'payload'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { createMunicipalityUpdateRecord } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
@ -15,6 +20,10 @@ import {
} from '../helpers/testDatabaseLease'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
/** Create data with server-managed fields (e.g. `author`) intentionally omitted. */
type MunicipalityUpdateCreateData = RequiredDataFromCollectionSlug<'municipalityUpdate'>
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -100,7 +109,7 @@ describe('campaign municipality update domain', () => {
author: 999,
createdAt: '2000-01-01T00:00:00.000Z',
updatedAt: '2000-01-01T00:00:00.000Z',
} as never)
})
expect(parsed).toEqual({
municipality: 1,
@ -116,11 +125,11 @@ describe('campaign municipality update domain', () => {
await expect(
payload.create({
collection: 'municipalityUpdate',
data: {
data: stub<MunicipalityUpdateCreateData>({
municipality: municipality.id,
kind: 'semanal',
worked: 'Somente uma resposta',
} as never,
}),
user: coordinator,
overrideAccess: false,
}),
@ -128,11 +137,11 @@ describe('campaign municipality update domain', () => {
await expect(
payload.create({
collection: 'municipalityUpdate',
data: {
data: stub<MunicipalityUpdateCreateData>({
municipality: municipality.id,
kind: 'urgente',
body: ' ',
} as never,
}),
user: coordinator,
overrideAccess: false,
}),
@ -146,7 +155,7 @@ describe('campaign municipality update domain', () => {
? await MunicipalityUpdate.access.update({
id: 1,
data: undefined,
req: { user: coordinator } as never,
req: stub<PayloadRequest>({ user: coordinator }),
})
: MunicipalityUpdate.access?.update
const deleteAccess =
@ -154,7 +163,7 @@ describe('campaign municipality update domain', () => {
? await MunicipalityUpdate.access.delete({
id: 1,
data: undefined,
req: { user: coordinator } as never,
req: stub<PayloadRequest>({ user: coordinator }),
})
: MunicipalityUpdate.access?.delete
@ -170,16 +179,18 @@ describe('campaign municipality update domain', () => {
it('lets the coordinator create and read every update', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const created = await createMunicipalityUpdateRecord(payload, coordinator, {
// Forged `author` goes through a widened variable: the schema must strip it.
const forgedInput = {
municipality: municipality.id,
kind: 'semanal',
kind: 'semanal' as const,
worked: 'Visitas concluídas',
failed: 'Dois endereços fechados',
needs: 'Mais panfletos',
activeVolunteers: 7,
newSupports: 12,
author: 999,
} as never)
}
const created = await createMunicipalityUpdateRecord(payload, coordinator, forgedInput)
expect(created.author).toBe(coordinator.id)
const visible = await listMunicipalityUpdates(coordinator, municipality.id)
@ -278,11 +289,11 @@ describe('campaign municipality update domain', () => {
await expect(
payload.create({
collection: 'municipalityUpdate',
data: {
data: stub<MunicipalityUpdateCreateData>({
municipality: municipality.id,
kind: 'nota',
body: 'Tentativa anônima',
} as never,
}),
overrideAccess: false,
}),
).rejects.toThrow()
@ -537,7 +548,7 @@ describe('campaign municipality update domain', () => {
) {
throw new Error('falha forçada no campo derivado')
}
return originalUpdate(args as never) as never
return originalUpdate(args)
})
try {

View file

@ -1,6 +1,12 @@
// @vitest-environment node
import { getPayload, type Field, type Payload, type PayloadRequest } from 'payload'
import {
getPayload,
type Field,
type FieldAccess,
type Payload,
type PayloadRequest,
} from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { CampaignUser as CampaignUserCollection } from '@/collections/CampaignUser'
@ -8,6 +14,9 @@ import type { CampaignUser } from '@/payload-types'
import config from '@/payload.config'
import { withCampaignFixtures, type CampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
type FieldAccessArgs = Parameters<FieldAccess>[0]
let payload: Payload
@ -233,14 +242,18 @@ describe('campaign user contact phone', () => {
user: coordinator,
} as unknown as PayloadRequest
await expect(field.access?.update?.({ id: owner.id, req: ownerReq } as never)).resolves.toBe(
true,
await expect(
field.access?.update?.(stub<FieldAccessArgs>({ id: owner.id, req: ownerReq })),
).resolves.toBe(true)
await expect(
field.access?.update?.(stub<FieldAccessArgs>({ id: owner.id, req: coordinatorReq })),
).resolves.toBe(true)
await expect(field.access?.create?.(stub<FieldAccessArgs>({ req: ownerReq }))).resolves.toBe(
false,
)
await expect(
field.access?.update?.({ id: owner.id, req: coordinatorReq } as never),
field.access?.create?.(stub<FieldAccessArgs>({ req: coordinatorReq })),
).resolves.toBe(true)
await expect(field.access?.create?.({ req: ownerReq } as never)).resolves.toBe(false)
await expect(field.access?.create?.({ req: coordinatorReq } as never)).resolves.toBe(true)
})
})
})

View file

@ -1,13 +1,15 @@
// @vitest-environment node
import { getPayload, type Payload } from 'payload'
import { getPayload, type Payload, type RequiredDataFromCollectionSlug } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { createLeaderSupporterRecord } from '@/app/(campaign)/campanha/actions/leaderSupporter'
import config from '@/payload.config'
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '@/utilities/campaignConsent'
import { relationshipId } from '@/utilities/relationship'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
import {
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
withLeasedConsent,
@ -112,10 +114,11 @@ describe('CMS/PII collection access lockdown', () => {
await expect(
payload.create({
collection,
data: {},
// Denied before validation: an empty payload stands in for required fields.
data: stub<RequiredDataFromCollectionSlug<typeof collection>>({}),
user: coordinator,
overrideAccess: false,
} as never),
}),
).rejects.toThrow(/permissão|not allowed/i)
},
)
@ -159,8 +162,8 @@ describe('leader supporter creation scope', () => {
fixtures.own('supporter', supporter.id)
expect(supporter.source).toBe('lideranca')
expect(fixtures.id(supporter.createdBy as never)).toBe(leaderAccount.id)
expect(fixtures.id(supporter.municipality as never)).toBe(linked.id)
expect(relationshipId(supporter.createdBy)).toBe(leaderAccount.id)
expect(relationshipId(supporter.municipality)).toBe(linked.id)
})
it('rejects a supporter outside the leadership municipalities', async () => {

View file

@ -19,15 +19,19 @@ vi.mock('@/utilities/phone', async (importOriginal) => {
import { Contact } from '@/collections/Contact'
import { leadershipCreateSchema } from '@/lib/schemas/leadership'
import { stub } from '../helpers/stub'
const contactPhoneHook = Contact.hooks?.beforeValidate?.[0]
type ContactPhoneHookArgs = Parameters<NonNullable<typeof contactPhoneHook>>[0]
const runContactPhoneHook = async (phone: string) => {
if (typeof contactPhoneHook !== 'function') {
throw new Error('Expected Contact.beforeValidate phone hook.')
}
const data = { phone }
await contactPhoneHook({ data } as never)
await contactPhoneHook(stub<ContactPhoneHookArgs>({ data }))
return data.phone
}
@ -54,9 +58,7 @@ describe('Contact phone normalization boundary', () => {
})
it('rejects an invalid canonical-length direct API phone without re-normalizing it', async () => {
await expect(runContactPhoneHook('00000000000')).rejects.toThrow(
'Celular brasileiro inválido.',
)
await expect(runContactPhoneHook('00000000000')).rejects.toThrow('Celular brasileiro inválido.')
expect(phoneMocks.normalizeBrazilianPhone).not.toHaveBeenCalled()
})
})

View file

@ -10,6 +10,12 @@ import { provisionOnda0ConsentAndPrivacy } from '@/utilities/onda0Provision'
import { assertTestDatabase } from '../helpers/assertTestDatabase'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import {
CAMPAIGN_INVITE_CONSENT_LEASE_KEY,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY,
withSharedTestDatabaseLease,
} from '../helpers/testDatabaseLease'
let payload: Payload
@ -27,20 +33,29 @@ describe('Onda 0 provision (integration)', () => {
})
it('upserts consent keys and publishes privacy-policy idempotently', async () => {
await provisionOnda0ConsentAndPrivacy(payload)
await provisionOnda0ConsentAndPrivacy(payload)
// Shared leases on every stable consent key: parallel spec files exercise
// "consent missing" fail-closed paths by deleting these rows under an
// EXCLUSIVE lease — without the shared leases this test races that window.
await withSharedTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, () =>
withSharedTestDatabaseLease(payload, SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, () =>
withSharedTestDatabaseLease(payload, SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY, async () => {
await provisionOnda0ConsentAndPrivacy(payload)
await provisionOnda0ConsentAndPrivacy(payload)
for (const { key } of ONDA0_CONSENT_ENTRIES) {
const descriptor = await requireConsentByKey(payload, key)
expect(descriptor.key).toBe(key)
expect(descriptor.contentHash).toMatch(/^[a-f0-9]{64}$/)
}
for (const { key } of ONDA0_CONSENT_ENTRIES) {
const descriptor = await requireConsentByKey(payload, key)
expect(descriptor.key).toBe(key)
expect(descriptor.contentHash).toMatch(/^[a-f0-9]{64}$/)
}
const privacy = await payload.findGlobal({
slug: 'privacy-policy',
overrideAccess: true,
})
expect(privacy.published).toBe(true)
expect(privacy.body).toBeTruthy()
const privacy = await payload.findGlobal({
slug: 'privacy-policy',
overrideAccess: true,
})
expect(privacy.published).toBe(true)
expect(privacy.body).toBeTruthy()
}),
),
)
})
})

View file

@ -1,9 +1,14 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { describe, expect, it, vi } from 'vitest'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { stub } from '../helpers/stub'
type DatabaseSession = NonNullable<Payload['db']['sessions']>[string]
const createPayload = ({
transactionID = 17,
commitError,
@ -27,15 +32,15 @@ const createPayload = ({
return {
database,
payload: {
db: {
db: stub<Payload['db']>({
beginTransaction,
commitTransaction,
rollbackTransaction,
sessions:
transactionID === null || !hasSession
? {}
: { [String(transactionID)]: { db: database } },
},
: { [String(transactionID)]: stub<DatabaseSession>({ db: database }) },
}),
},
beginTransaction,
commitTransaction,
@ -51,7 +56,7 @@ describe('withPayloadTransaction', () => {
return { req }
})
const result = await withPayloadTransaction(fixture.payload as never, callback)
const result = await withPayloadTransaction(fixture.payload, callback)
expect(result.req).toBe(callback.mock.calls[0]![0].req)
expect(fixture.commitTransaction).toHaveBeenCalledWith(17)
@ -63,7 +68,7 @@ describe('withPayloadTransaction', () => {
const callback = vi.fn()
await expect(
withPayloadTransaction(fixture.payload as never, callback, {
withPayloadTransaction(fixture.payload, callback, {
beginFailureMessage: 'falha específica',
}),
).rejects.toThrow('falha específica')
@ -78,7 +83,7 @@ describe('withPayloadTransaction', () => {
const original = new Error('callback falhou')
await expect(
withPayloadTransaction(fixture.payload as never, async () => {
withPayloadTransaction(fixture.payload, async () => {
throw original
}),
).rejects.toBe(original)
@ -91,9 +96,9 @@ describe('withPayloadTransaction', () => {
const original = new Error('commit falhou')
const fixture = createPayload({ commitError: original })
await expect(
withPayloadTransaction(fixture.payload as never, async () => 'result'),
).rejects.toBe(original)
await expect(withPayloadTransaction(fixture.payload, async () => 'result')).rejects.toBe(
original,
)
expect(fixture.commitTransaction).toHaveBeenCalledWith(17)
expect(fixture.rollbackTransaction).toHaveBeenCalledWith(17)
@ -104,7 +109,7 @@ describe('withPayloadTransaction', () => {
const rollback = new Error('rollback falhou')
const fixture = createPayload({ rollbackError: rollback })
const failure = await withPayloadTransaction(fixture.payload as never, async () => {
const failure = await withPayloadTransaction(fixture.payload, async () => {
throw original
}).catch((error: unknown) => error)
@ -118,10 +123,9 @@ describe('withPayloadTransaction', () => {
const rollback = new Error('rollback falhou')
const fixture = createPayload({ commitError: commit, rollbackError: rollback })
const failure = await withPayloadTransaction(
fixture.payload as never,
async () => 'result',
).catch((error: unknown) => error)
const failure = await withPayloadTransaction(fixture.payload, async () => 'result').catch(
(error: unknown) => error,
)
expect(failure).toBeInstanceOf(AggregateError)
expect((failure as AggregateError).errors).toEqual([commit, rollback])

View file

@ -17,6 +17,9 @@ import {
getTestTransactionBackendPID,
waitForAdvisoryLockWaiter,
} from '../helpers/testDatabaseLease'
import { stub } from '../helpers/stub'
type DatabaseSession = NonNullable<Payload['db']['sessions']>[string]
let payload: Payload
@ -64,30 +67,28 @@ describe('PostgreSQL transaction advisory locks', () => {
'contact-phone:71999990001',
'contact-phone:71999990002',
])
expect(CONTACT_PHONE_CONFLICT_MESSAGE).toBe(
'Já existe outro contato com este celular.',
)
expect(CONTACT_PHONE_CONFLICT_MESSAGE).toBe('Já existe outro contato com este celular.')
})
it('uses only the exact Payload transaction session and fails closed without it', async () => {
const sessionDatabase = { execute: vi.fn().mockResolvedValue(undefined) }
const unrelatedDatabase = { execute: vi.fn().mockResolvedValue(undefined) }
const fixture = {
db: {
db: stub<Payload['db']>({
name: 'postgres',
sessions: {
'41': { db: sessionDatabase },
'42': { db: unrelatedDatabase },
'41': stub<DatabaseSession>({ db: sessionDatabase }),
'42': stub<DatabaseSession>({ db: unrelatedDatabase }),
},
},
}),
}
await expect(
getPostgresTransactionDatabase(fixture as never, {
getPostgresTransactionDatabase(fixture, {
transactionID: Promise.resolve(41),
}),
).resolves.toBe(sessionDatabase)
await acquireTextAdvisoryLocks(fixture as never, { transactionID: 41 }, [
await acquireTextAdvisoryLocks(fixture, { transactionID: 41 }, [
'domain:2',
'domain:1',
'domain:1',
@ -98,31 +99,28 @@ describe('PostgreSQL transaction advisory locks', () => {
expect(collectLockParams(query)).toEqual(['domain:1', 'domain:2'])
expect(unrelatedDatabase.execute).not.toHaveBeenCalled()
await expect(getPostgresTransactionDatabase(fixture as never, {})).rejects.toThrow(
await expect(getPostgresTransactionDatabase(fixture, {})).rejects.toThrow(
'A transação PostgreSQL não está disponível.',
)
await expect(
acquireTextAdvisoryLocks(fixture as never, {}, ['domain:1']),
).rejects.toThrow('A transação PostgreSQL não está disponível.')
await expect(
getPostgresTransactionDatabase(fixture as never, { transactionID: 99 }),
).rejects.toThrow('A sessão PostgreSQL da transação não está disponível.')
await expect(acquireTextAdvisoryLocks(fixture, {}, ['domain:1'])).rejects.toThrow(
'A transação PostgreSQL não está disponível.',
)
await expect(getPostgresTransactionDatabase(fixture, { transactionID: 99 })).rejects.toThrow(
'A sessão PostgreSQL da transação não está disponível.',
)
})
it('rejects invalid text keys before acquiring any lock', async () => {
const database = { execute: vi.fn().mockResolvedValue(undefined) }
const fixture = {
db: {
db: stub<Payload['db']>({
name: 'postgres',
sessions: { '51': { db: database } },
},
sessions: { '51': stub<DatabaseSession>({ db: database }) },
}),
}
await expect(
acquireTextAdvisoryLocks(fixture as never, { transactionID: 51 }, [
'valid:key',
' ',
]),
acquireTextAdvisoryLocks(fixture, { transactionID: 51 }, ['valid:key', ' ']),
).rejects.toThrow('A chave do bloqueio PostgreSQL deve ser um texto não vazio.')
expect(database.execute).not.toHaveBeenCalled()
})

View file

@ -16,9 +16,7 @@ describe('campaign action context', () => {
const findByID = vi.fn().mockResolvedValue(currentActor)
const req = { transactionID: 21 }
await expect(reloadCampaignActor({ findByID } as never, staleActor, req)).resolves.toBe(
currentActor,
)
await expect(reloadCampaignActor({ findByID }, staleActor, req)).resolves.toBe(currentActor)
expect(findByID).toHaveBeenCalledWith({
collection: 'campaignUser',
id: staleActor.id,

View file

@ -2,6 +2,7 @@
import { describe, expect, it } from 'vitest'
import type { Media } from '@/payload-types'
import {
assertCampaignAvatarFile,
campaignUserInitials,
@ -9,6 +10,8 @@ import {
mediaDocumentUrl,
} from '@/utilities/campaignUserProfile'
import { stub } from '../helpers/stub'
describe('campaignUserProfile helpers', () => {
it('builds initials from the user name', () => {
expect(campaignUserInitials('Maria Silva')).toBe('MS')
@ -20,7 +23,7 @@ describe('campaignUserProfile helpers', () => {
campaignUserShellView({
name: 'Maria',
role: 'coordinator',
avatar: { id: 1, url: 'https://example.com/a.jpg' } as never,
avatar: stub<Media>({ id: 1, url: 'https://example.com/a.jpg' }),
}),
).toEqual({
name: 'Maria',

View file

@ -1,94 +0,0 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { describe, expect, it, vi } from 'vitest'
import type { CampaignUser, User } from '@/payload-types'
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
import * as drizzleBulk from '@/utilities/drizzleBulk'
import { loadFederalCandidateTotalsAggregated } from '@/utilities/federalCandidateTotalsAggregate'
import type { MunicipalityElectionGeography } from '@/utilities/municipalityElectionGeography'
describe('assertCanReadElectionData', () => {
it('allows payload admins and staff campaign users to read election data', () => {
expect(() =>
assertCanReadElectionData({
collection: 'users',
id: 1,
} as User),
).not.toThrow()
expect(() =>
assertCanReadElectionData({
collection: 'campaignUser',
id: 1,
role: 'advisor',
} as unknown as CampaignUser),
).not.toThrow()
expect(() =>
assertCanReadElectionData({
collection: 'campaignUser',
id: 1,
role: 'leader',
} as unknown as CampaignUser),
).toThrow('Leitura de dados eleitorais negada.')
})
it('denies readers outside campaign/admin collections', () => {
expect(() =>
assertCanReadElectionData({
collection: 'contact',
id: 1,
} as never),
).toThrow('Leitura de dados eleitorais negada.')
})
})
describe('loadFederalCandidateTotalsAggregated', () => {
it('maps drizzle aggregate rows and scopes geography in SQL', async () => {
const geography: MunicipalityElectionGeography = {
cityCode: '38490',
zones: [1, 2],
}
const execute = vi.fn().mockResolvedValue({
rows: [
{
candidate_number: 1313,
candidate_name: 'JORGE SOLLA',
party: 'PT',
votes: 2100,
},
{
candidate_number: 1234,
candidate_name: 'OUTRO',
party: 'PL',
votes: 900,
},
],
})
vi.spyOn(drizzleBulk, 'requirePostgresDrizzle').mockReturnValue({ execute })
const payload = { db: { name: 'postgres' } } as Pick<Payload, 'db'>
const totals = await loadFederalCandidateTotalsAggregated(
payload,
{
collection: 'campaignUser',
id: 1,
role: 'coordinator',
} as unknown as CampaignUser,
geography,
)
expect(totals).toEqual([
{ candidateNumber: 1313, name: 'JORGE SOLLA', party: 'PT', votes: 2100 },
{ candidateNumber: 1234, name: 'OUTRO', party: 'PL', votes: 900 },
])
expect(execute).toHaveBeenCalledOnce()
const sqlPayload = JSON.stringify(execute.mock.calls[0]?.[0])
expect(sqlPayload).toContain('city_code')
expect(sqlPayload).not.toContain('city_name')
vi.restoreAllMocks()
})
})

View file

@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -18,204 +22,24 @@
}
],
"paths": {
"@/*": ["./src/*"],
"@payload-config": ["./src/payload.config.ts"]
"@/*": [
"./src/*"
],
"@payload-config": [
"./src/payload.config.ts"
]
},
"target": "ES2022"
"target": "ES2022",
"noUnusedLocals": true
},
"include": [
"**/*.ts",
"**/*.tsx",
".next/build-composition/types/**/*.ts",
".next/build-vote-estimate-version-final/types/**/*.ts",
".next/build-vote-estimate-version/types/**/*.ts",
".next/dev/types/**/*.ts",
".next/e2e-10044/types/**/*.ts",
".next/e2e-1006/types/**/*.ts",
".next/e2e-1085/types/**/*.ts",
".next/e2e-11201/types/**/*.ts",
".next/e2e-11506/types/**/*.ts",
".next/e2e-11663/types/**/*.ts",
".next/e2e-12280/types/**/*.ts",
".next/e2e-13120/types/**/*.ts",
".next/e2e-13521/types/**/*.ts",
".next/e2e-14136/types/**/*.ts",
".next/e2e-14928/types/**/*.ts",
".next/e2e-15205/types/**/*.ts",
".next/e2e-1536/types/**/*.ts",
".next/e2e-15855/types/**/*.ts",
".next/e2e-15927/types/**/*.ts",
".next/e2e-16483/types/**/*.ts",
".next/e2e-16874/types/**/*.ts",
".next/e2e-17058/types/**/*.ts",
".next/e2e-17359/types/**/*.ts",
".next/e2e-1764/types/**/*.ts",
".next/e2e-18285/types/**/*.ts",
".next/e2e-18508/types/**/*.ts",
".next/e2e-18580/types/**/*.ts",
".next/e2e-18785/types/**/*.ts",
".next/e2e-19365/types/**/*.ts",
".next/e2e-19899/types/**/*.ts",
".next/e2e-20552/types/**/*.ts",
".next/e2e-21365/types/**/*.ts",
".next/e2e-21464/types/**/*.ts",
".next/e2e-21632/types/**/*.ts",
".next/e2e-2180/types/**/*.ts",
".next/e2e-22000/types/**/*.ts",
".next/e2e-22628/types/**/*.ts",
".next/e2e-22922/types/**/*.ts",
".next/e2e-23174/types/**/*.ts",
".next/e2e-23318/types/**/*.ts",
".next/e2e-23628/types/**/*.ts",
".next/e2e-2427/types/**/*.ts",
".next/e2e-24574/types/**/*.ts",
".next/e2e-25365/types/**/*.ts",
".next/e2e-25431/types/**/*.ts",
".next/e2e-25587/types/**/*.ts",
".next/e2e-27635/types/**/*.ts",
".next/e2e-2815/types/**/*.ts",
".next/e2e-28280/types/**/*.ts",
".next/e2e-28367/types/**/*.ts",
".next/e2e-28783/types/**/*.ts",
".next/e2e-28804/types/**/*.ts",
".next/e2e-29103/types/**/*.ts",
".next/e2e-30214/types/**/*.ts",
".next/e2e-303/types/**/*.ts",
".next/e2e-30397/types/**/*.ts",
".next/e2e-30528/types/**/*.ts",
".next/e2e-31313/types/**/*.ts",
".next/e2e-31599/types/**/*.ts",
".next/e2e-32003/types/**/*.ts",
".next/e2e-3335/types/**/*.ts",
".next/e2e-33840/types/**/*.ts",
".next/e2e-34312/types/**/*.ts",
".next/e2e-36806/types/**/*.ts",
".next/e2e-36946/types/**/*.ts",
".next/e2e-37023/types/**/*.ts",
".next/e2e-38948/types/**/*.ts",
".next/e2e-39539/types/**/*.ts",
".next/e2e-39565/types/**/*.ts",
".next/e2e-40345/types/**/*.ts",
".next/e2e-41065/types/**/*.ts",
".next/e2e-41648/types/**/*.ts",
".next/e2e-42183/types/**/*.ts",
".next/e2e-43276/types/**/*.ts",
".next/e2e-43384/types/**/*.ts",
".next/e2e-44522/types/**/*.ts",
".next/e2e-44774/types/**/*.ts",
".next/e2e-44960/types/**/*.ts",
".next/e2e-46155/types/**/*.ts",
".next/e2e-46497/types/**/*.ts",
".next/e2e-46609/types/**/*.ts",
".next/e2e-46757/types/**/*.ts",
".next/e2e-46875/types/**/*.ts",
".next/e2e-47405/types/**/*.ts",
".next/e2e-47939/types/**/*.ts",
".next/e2e-48016/types/**/*.ts",
".next/e2e-48479/types/**/*.ts",
".next/e2e-49401/types/**/*.ts",
".next/e2e-51002/types/**/*.ts",
".next/e2e-52013/types/**/*.ts",
".next/e2e-52421/types/**/*.ts",
".next/e2e-52695/types/**/*.ts",
".next/e2e-53562/types/**/*.ts",
".next/e2e-55001/types/**/*.ts",
".next/e2e-55800/types/**/*.ts",
".next/e2e-55954/types/**/*.ts",
".next/e2e-56773/types/**/*.ts",
".next/e2e-56925/types/**/*.ts",
".next/e2e-57962/types/**/*.ts",
".next/e2e-58103/types/**/*.ts",
".next/e2e-59966/types/**/*.ts",
".next/e2e-60290/types/**/*.ts",
".next/e2e-60609/types/**/*.ts",
".next/e2e-60813/types/**/*.ts",
".next/e2e-61668/types/**/*.ts",
".next/e2e-62163/types/**/*.ts",
".next/e2e-63096/types/**/*.ts",
".next/e2e-63375/types/**/*.ts",
".next/e2e-64189/types/**/*.ts",
".next/e2e-64863/types/**/*.ts",
".next/e2e-6491/types/**/*.ts",
".next/e2e-64996/types/**/*.ts",
".next/e2e-66448/types/**/*.ts",
".next/e2e-67280/types/**/*.ts",
".next/e2e-677/types/**/*.ts",
".next/e2e-67863/types/**/*.ts",
".next/e2e-6790/types/**/*.ts",
".next/e2e-68693/types/**/*.ts",
".next/e2e-69683/types/**/*.ts",
".next/e2e-70258/types/**/*.ts",
".next/e2e-70974/types/**/*.ts",
".next/e2e-72843/types/**/*.ts",
".next/e2e-73012/types/**/*.ts",
".next/e2e-7321/types/**/*.ts",
".next/e2e-74412/types/**/*.ts",
".next/e2e-75948/types/**/*.ts",
".next/e2e-76503/types/**/*.ts",
".next/e2e-76880/types/**/*.ts",
".next/e2e-78072/types/**/*.ts",
".next/e2e-78180/types/**/*.ts",
".next/e2e-78226/types/**/*.ts",
".next/e2e-78655/types/**/*.ts",
".next/e2e-79761/types/**/*.ts",
".next/e2e-8028/types/**/*.ts",
".next/e2e-80284/types/**/*.ts",
".next/e2e-80317/types/**/*.ts",
".next/e2e-81160/types/**/*.ts",
".next/e2e-81409/types/**/*.ts",
".next/e2e-8147/types/**/*.ts",
".next/e2e-81635/types/**/*.ts",
".next/e2e-81786/types/**/*.ts",
".next/e2e-81905/types/**/*.ts",
".next/e2e-83151/types/**/*.ts",
".next/e2e-83738/types/**/*.ts",
".next/e2e-84154/types/**/*.ts",
".next/e2e-84504/types/**/*.ts",
".next/e2e-84574/types/**/*.ts",
".next/e2e-85918/types/**/*.ts",
".next/e2e-8611/types/**/*.ts",
".next/e2e-86120/types/**/*.ts",
".next/e2e-86502/types/**/*.ts",
".next/e2e-87073/types/**/*.ts",
".next/e2e-87586/types/**/*.ts",
".next/e2e-87665/types/**/*.ts",
".next/e2e-88316/types/**/*.ts",
".next/e2e-88470/types/**/*.ts",
".next/e2e-89042/types/**/*.ts",
".next/e2e-89097/types/**/*.ts",
".next/e2e-89236/types/**/*.ts",
".next/e2e-89710/types/**/*.ts",
".next/e2e-90349/types/**/*.ts",
".next/e2e-90697/types/**/*.ts",
".next/e2e-91110/types/**/*.ts",
".next/e2e-91126/types/**/*.ts",
".next/e2e-92653/types/**/*.ts",
".next/e2e-92722/types/**/*.ts",
".next/e2e-93003/types/**/*.ts",
".next/e2e-93255/types/**/*.ts",
".next/e2e-93729/types/**/*.ts",
".next/e2e-9379/types/**/*.ts",
".next/e2e-94851/types/**/*.ts",
".next/e2e-94897/types/**/*.ts",
".next/e2e-95028/types/**/*.ts",
".next/e2e-95103/types/**/*.ts",
".next/e2e-95457/types/**/*.ts",
".next/e2e-96105/types/**/*.ts",
".next/e2e-96857/types/**/*.ts",
".next/e2e-96909/types/**/*.ts",
".next/e2e-97296/types/**/*.ts",
".next/e2e-97674/types/**/*.ts",
".next/e2e-9850/types/**/*.ts",
".next/e2e-99093/types/**/*.ts",
".next/e2e-99443/types/**/*.ts",
".next/e2e-99541/types/**/*.ts",
".next/e2e-9955/types/**/*.ts",
".next/e2e-99590/types/**/*.ts",
".next/e2e/types/**/*.ts",
".next/types/**/*.ts",
"next-env.d.ts",
".next/e2e-12203/types/**/*.ts"
".next/types/**/*.ts",
".next/*/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": [
"node_modules"
]
}