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:
parent
473c82d09d
commit
0e4e6855dc
58 changed files with 1291 additions and 1906 deletions
68
.github/workflows/ci.yml
vendored
Normal file
68
.github/workflows/ci.yml
vendored
Normal 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
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
46
docs/IMPROVE-CODE-QUALITY-PLAN.md
Normal file
46
docs/IMPROVE-CODE-QUALITY-PLAN.md
Normal 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
54
docs/TECH-DEBT.md
Normal 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
32
docs/TESTING.md
Normal 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.
|
||||
|
|
@ -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
31
knip.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
package.json
13
package.json
|
|
@ -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"
|
||||
|
|
|
|||
1009
pnpm-lock.yaml
1009
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import config from '@payload-config'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
),
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -156,7 +156,6 @@ export const MunicipalityList = ({
|
|||
</TableHeader>
|
||||
<TableBody>
|
||||
{municipalities.map((municipality) => {
|
||||
const names = advisorNames(municipality)
|
||||
const hasAdvisor = municipality.advisorIDs.length > 0
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}))
|
||||
}
|
||||
13
src/utilities/hookFilledData.ts
Normal file
13
src/utilities/hookFilledData.ts
Normal 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>
|
||||
|
|
@ -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
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
7
tests/helpers/stub.ts
Normal 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
|
||||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
212
tsconfig.json
212
tsconfig.json
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue