Isolate local/dev and tests from the production database

Local dev and the test suite previously defaulted to the live production
Neon database, and running the tests mutated prod data. Add guardrails plus
a proper local Postgres + migrations workflow so production can't be hit by
mistake:

- docker-compose: local Postgres 17 (matches prod) + auto-created teqo_test
- dev guard refuses a non-local DATABASE_URL; tests refuse non-_test databases
- .env.test isolates the suite; vitest/playwright load it and fail closed
- Payload migrations set up (push disabled); build runs `payload migrate`,
  so deploys apply pending migrations to prod automatically
- baseline `initial` migration + idempotent consent.text varchar->jsonb fix
- `pnpm db:pull` copies prod content locally, excluding supporter PII
- docs (README, AGENTS.md) + agent skills for the local-db/migration workflow
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-15 13:52:30 -03:00
parent 715e3d051e
commit 4f0f997c32
20 changed files with 4210 additions and 87 deletions

View file

@ -0,0 +1,63 @@
---
name: local-database
description: Set up, refresh, and safely use the Teqo local Postgres database, and copy production content locally with `pnpm db:pull`. Use when starting local development, when the local database is empty or stale, when preparing the test database, or when the user mentions the local DB, docker-compose, db:pull, or seeding local data. Enforces that local development and tests never touch the production Neon database.
---
# Teqo Local Database
Production is a live **Neon Postgres** with real citizens' PII. Local development and tests run against a **local Docker Postgres** and must never connect to production.
## Guardrails already in place (do not weaken them)
- `pnpm dev` runs `scripts/guard-dev-db.mjs` and **refuses to start** if `DATABASE_URL` is a non-local host. Escape hatch: `ALLOW_REMOTE_DB=true` (use only with a very good reason).
- Tests load `.env.test` (database `teqo_test`) and call `assertTestDatabase()` (`tests/helpers/assertTestDatabase.ts`), which **throws** unless the database name ends in `_test`.
- Never repoint `DATABASE_URL` in `.env`, `.env.local`, or `.env.test` at Neon. `.env.test` is committed and local-only on purpose.
## Local connection values
- Dev database: `postgresql://teqo:teqo@localhost:5432/teqo`
- Test database: `postgresql://teqo:teqo@localhost:5432/teqo_test`
- Local Postgres major version is pinned to **17** in `docker-compose.yml` to match production Neon.
## First-time setup
1. Ensure Docker is running (`open -a Docker` on macOS if the daemon is down).
2. `pnpm db:start` — boots local Postgres and auto-creates `teqo_test`.
3. Set `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo` in `.env.local`.
4. Load schema + data (pick one):
- Mirror prod content (recommended): see "Refresh local content from prod" below.
- Empty schema from migrations: `pnpm migrate`.
5. `pnpm dev`.
Stop the database with `pnpm db:stop`.
## Refresh local content from prod (`pnpm db:pull`)
```bash
PROD_DATABASE_URL="<unpooled Neon URL>" pnpm db:pull
```
- The unpooled URL is `DATABASE_URL_UNPOOLED` in `.env.local`.
- `scripts/db-pull.mjs` only **reads** prod (via `pg_dump`) and only **writes** local; it refuses any non-local target.
- It **excludes PII**: row data of `contact`, `signature`, `subscription` (and their `_rels`) is not copied; the table structure is kept.
- It resets and rebuilds the local `public` schema, so it overwrites local data.
## Prepare / reset the test database
The test DB needs the schema applied via migrations:
```bash
DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate
```
Then run `pnpm test` (or `pnpm test:int` / `pnpm test:e2e`). Playwright injects the test DB into its own dev server and never reuses a running one.
## Never do
- Point any `DATABASE_URL` at Neon for dev or tests.
- Run the test suite against a database not ending in `_test`.
- Try to use `db:pull` to push local → prod (it cannot; it is read-only on the source).
## Related
- Schema changes and migrations: use the `payload-migrations` skill.

View file

@ -0,0 +1,74 @@
---
name: payload-migrations
description: Create, apply, and deploy Payload schema migrations for the Teqo project (Postgres, push disabled). Use when changing any collection/global/field config, adding indexes or enums, reconciling schema drift, writing a data migration, or when the user mentions migrations, migrate:create, or applying schema changes to production. Explains how migrations reach the production Neon database on deploy.
---
# Teqo Payload Migrations
Schema is managed exclusively by committed migrations in `src/migrations/`. `push: false` is set in `payload.config.ts` for every environment — never enable it against a remote database.
## How it deploys
`pnpm build` runs `payload migrate` before `next build`. On Vercel, the build runs with the production `DATABASE_URL`, so **every deploy applies pending migrations to production automatically**. Do not run `pnpm migrate` against production by hand.
Production is already baselined at migration `20260715_163458_initial` (marked applied in prod's `payload_migrations`). **Never regenerate, rename, or replace the initial migration** — doing so would make prod try to recreate existing tables.
## Making a schema change
1. Make sure the local DB is running and current: `pnpm db:start`, `DATABASE_URL` local, `pnpm migrate`.
2. Edit the collection / global / field config.
3. Generate the migration:
```bash
pnpm migrate:create <name>
```
4. **Review** the generated SQL in `src/migrations/<timestamp>_<name>.ts`. Confirm it does only what you intend (watch for unintended drops/renames).
5. Commit all generated files: the `.ts`, the `.json` snapshot, and the updated `src/migrations/index.ts`.
6. Apply locally and verify: `pnpm migrate` (inspect with `pnpm migrate:status`).
7. If types or the import map are affected: `pnpm generate:types` / `pnpm generate:importmap`.
8. Deploying (merge/deploy) applies it to prod via the build step.
## Hand-written data / reconciliation migrations
For changes Payload's diff can't generate (data backfills, type reconciliations), add a file manually and register it in `index.ts`. Make it **idempotent** so it's safe on databases already in the target state.
Follow the existing example `src/migrations/20260715_163500_consent_text_to_jsonb.ts`:
```ts
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
DO $$
BEGIN
IF EXISTS ( /* guard: only run when not already in target state */ ) THEN
/* ALTER / UPDATE ... */
END IF;
END $$;
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
/* reverse, also guarded */
}
```
Then add both `up`/`down` and a `name` entry to `src/migrations/index.ts` (order matters — after the previous migration).
## Verifying against production (read-only, safe)
To confirm what a deploy will apply, run status pointed at prod's unpooled URL — this only reads:
```bash
DATABASE_URL="<unpooled Neon URL>" pnpm migrate:status
```
## Never do
- Set `push: true`, especially against a remote DB.
- Run `pnpm migrate` manually against production (deploys handle it).
- Edit production schema by hand — make it a migration instead.
- Modify or delete the baseline `20260715_163458_initial` migration.
## Related
- Local database setup, `db:pull`, and test DB: use the `local-database` skill.

View file

@ -1,4 +1,14 @@
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DATABASE
# Copy this file to `.env.local` for local development.
#
# For LOCAL DEVELOPMENT, DATABASE_URL must point at the local Postgres started by
# docker-compose (`pnpm db:start`). `pnpm dev` will REFUSE to start against a
# non-local host (e.g. the production Neon database) unless you explicitly set
# ALLOW_REMOTE_DB=true — this is intentional, to protect production data.
DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo
PAYLOAD_SECRET=YOUR_SECRET_HERE
BLOB_READ_WRITE_TOKEN=YOUR_BLOB_TOKEN_HERE
NEXT_PUBLIC_SITE_URL=https://jorgesolla.com.br
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Note: the automated test suite ignores this file and reads .env.test instead,
# and only ever runs against a database whose name ends with "_test".

13
.env.test Normal file
View file

@ -0,0 +1,13 @@
# Environment for the automated test suite (vitest + playwright).
#
# This file is committed ON PURPOSE: it contains no secrets, only local values.
# The test suite deletes and recreates records, so it MUST use an isolated,
# throwaway database. Tests load THIS file (not .env / .env.local) and refuse to
# run unless the database name ends with "_test" (see assertTestDatabase.ts).
#
# Requires the local Postgres from docker-compose (`pnpm db:start`), which
# auto-creates the teqo_test database on first run.
DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test
PAYLOAD_SECRET=test-only-secret-not-used-in-production
BLOB_READ_WRITE_TOKEN=
NEXT_PUBLIC_SITE_URL=http://localhost:3000

View file

@ -2,6 +2,40 @@
You are an expert Payload CMS developer. When working with Payload projects, follow these rules:
## Teqo Project Context (read this first)
Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public site + editorial CMS + (soon) an internal `/campanha` campaign-management area, all in this one Next.js + Payload app. Full architecture/decision doc lives outside this repo at `plano-arquitetura-campanha-2026.md` (Cowork workspace) — check it for the "why" behind decisions below.
**Locked decisions — do not relitigate without a new concrete reason:**
- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campanha)` internal campaign tool (roles: assessores). No separate Rust service, no separate frontend app for `/campanha` — model it as Payload collections (it already has native `point` fields + `near` queries for the political map).
- Hosting stays on Vercel for now. No self-host/Coolify migration in progress.
- Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL.
**Real conventions already established in this codebase (follow these, not just the generic patterns below):**
- `Contact` is the normalized "person" record (name, email, phone, state/city via `src/lib/cities.ts`, CEP). Don't create a parallel "person" collection for new features (e.g. líderanças/apoiadores) — add a join collection that relates to `Contact`, the same way `Signature` and `Subscription` do.
- Every action that writes to more than one collection (see `src/app/(frontend)/actions/*.ts`) wraps the writes in a Payload transaction (`payload.db.beginTransaction/commitTransaction/rollbackTransaction`) and passes `req: { transactionID }` to every `payload.create`/`payload.update` call. Follow this pattern for any new multi-collection write.
- LGPD consent is tracked via a `Consent` collection (richText, versioned) referenced by relationship from `Signature`/`Subscription`. Any new form/opt-in flow should link to a `Consent` document the same way — don't invent a new consent mechanism.
- Cache invalidation: collections/globals call `revalidateDocumentById` (`src/utilities/documents.ts`) / `revalidateGlobal` (`src/utilities/globals.ts`) in an `afterChange` hook. Add the same hook to any new collection/global that backs a public page.
- Admin UI is organized via `admin.group` (e.g. `'Abaixo-assinados'`, `'Contatos'`, `'Configurações'`, `'Paginas'`) — group new collections consistently instead of leaving them ungrouped.
- i18n: Payload admin defaults to `pt` (not `pt-BR` — see `payload.config.ts`, `payload/i18n/pt`).
**Database & local development (safety-critical — production is a live Neon Postgres with real citizens' PII):**
- Local dev and tests must NEVER touch production. Local dev runs against a local Docker Postgres (`pnpm db:start`); `pnpm dev` refuses a non-local `DATABASE_URL` (guard: `scripts/guard-dev-db.mjs`) unless `ALLOW_REMOTE_DB=true`. Tests load `.env.test` (`teqo_test`) and refuse any database whose name doesn't end in `_test` (`tests/helpers/assertTestDatabase.ts`). Never repoint `.env`/`.env.local`/`.env.test` `DATABASE_URL` at Neon. Full workflow lives in the `local-database` skill (`.cursor/skills/local-database`).
- Schema changes go through committed Payload migrations — `push: false` everywhere, never flip it on against a remote DB. Edit configs → `pnpm migrate:create <name>` → commit `src/migrations/*` (both `.ts` and `.json`, plus `index.ts`) → `pnpm migrate` locally. `pnpm build` runs `payload migrate`, so **every Vercel deploy applies pending migrations to prod**. Prod is baselined at migration `20260715_163458_initial` — never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in the `payload-migrations` skill (`.cursor/skills/payload-migrations`).
- `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit.
- To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data).
## Known Gaps (as of 2026-07-15 — resolve before relying on this file for onboarding new devs)
1. **`Users` has no `roles` field yet.** The RBAC examples below are generic Payload patterns, not yet implemented here — every admin user today has full access. Add `roles` (e.g. `admin`/`editor`/`assessor`) before opening the admin to more people or building `/campanha`.
2. **`Consent` document IDs are hardcoded** in code (e.g. `consent: 2` in `submitWhatsapp.ts`). Fragile — breaks silently if that document is ever recreated with a different ID, and blocks having a reproducible local seed. Consider referencing by a stable slug/key instead. (The column-type drift is fixed; the hardcoded-ID fragility remains.)
3. **No `Pages`/`Posts` collections yet** for institutional content (bio, propostas, notícias/imprensa). `HomePage` global today only has a single image field — most homepage content is still hardcoded in `src/app/(frontend)/(home)/page.tsx`. This is the biggest gap for reproducing jorgesolla.com.br content in Payload.
**Recently resolved (2026-07-15):** local Postgres now runs via `docker-compose.yml` (Postgres 17, matching prod, auto-creating `teqo_test`); Payload migrations are set up with prod baselined; dev/test database guards prevent accidental production access. See the two database skills.
## Core Principles
1. **TypeScript-First**: Always use TypeScript with proper types from Payload
@ -15,19 +49,23 @@ You are an expert Payload CMS developer. When working with Payload projects, fol
- To validate typescript correctness after modifying code run `tsc --noEmit`
- Generate import maps after creating or modifying components.
- Any change to a collection/global/field schema requires a migration: `pnpm migrate:create <name>`, then `pnpm migrate` locally. Never rely on `push` (it is `false`). See the `payload-migrations` skill.
## Project Structure
Generic Payload layout (aspirational — `access/` and `hooks/` as separate dirs don't exist yet in this repo; access control and hooks are currently written inline inside each collection/global file):
```
src/
├── app/
│ ├── (frontend)/ # Frontend routes
│ └── (payload)/ # Payload admin routes
├── collections/ # Collection configs
├── globals/ # Global configs
├── components/ # Custom React components
├── hooks/ # Hook functions
├── access/ # Access control functions
│ ├── (frontend)/ # Frontend routes + server actions (src/app/(frontend)/actions/*.ts)
│ ├── (payload)/ # Payload admin routes
│ └── (campanha)/ # NOT YET CREATED — planned internal campaign-management area
├── collections/ # Collection configs (Users, Media, Petition, Signature, Consent, Contact, Subscription)
├── globals/ # Global configs (SiteSettings, HomePage, Metadata)
├── components/ # Custom React components (incl. components/ui from shadcn)
├── lib/ # cities.ts, zod schemas (lib/schemas/*)
├── utilities/ # documents.ts / globals.ts (revalidation helpers)
└── payload.config.ts # Main config
```
@ -725,7 +763,6 @@ export const Posts: CollectionConfig = {
### Performance Best Practices
1. **Import correctly:**
- Admin Panel: `import { Button } from '@payloadcms/ui'`
- Frontend: `import { Button } from '@payloadcms/ui/elements/Button'`
@ -740,7 +777,6 @@ export const Posts: CollectionConfig = {
```
3. **Prefer Server Components** - Only use Client Components when you need:
- State (useState, useReducer)
- Effects (useEffect)
- Event handlers (onClick, onChange)
@ -1040,19 +1076,16 @@ For deeper exploration of specific topics, refer to the context files located in
### Available Context Files
1. **`payload-overview.md`** - High-level architecture and core concepts
- Payload structure and initialization
- Configuration fundamentals
- Database adapters overview
2. **`security-critical.md`** - Critical security patterns (⚠️ IMPORTANT)
- Local API access control
- Transaction safety in hooks
- Preventing infinite hook loops
3. **`collections.md`** - Collection configurations
- Basic collection patterns
- Auth collections with RBAC
- Upload collections
@ -1060,7 +1093,6 @@ For deeper exploration of specific topics, refer to the context files located in
- Globals
4. **`fields.md`** - Field types and patterns
- All field types with examples
- Conditional fields
- Virtual fields
@ -1068,13 +1100,11 @@ For deeper exploration of specific topics, refer to the context files located in
- Common field patterns
5. **`field-type-guards.md`** - TypeScript field type utilities
- Field type checking utilities
- Safe type narrowing
- Runtime field validation
6. **`access-control.md`** - Permission patterns
- Collection-level access
- Field-level access
- Row-level security
@ -1082,48 +1112,41 @@ For deeper exploration of specific topics, refer to the context files located in
- Multi-tenant access control
7. **`access-control-advanced.md`** - Complex access patterns
- Nested document access
- Cross-collection permissions
- Dynamic role hierarchies
- Performance optimization
8. **`hooks.md`** - Lifecycle hooks
- Collection hooks
- Field hooks
- Hook context patterns
- Common hook recipes
9. **`queries.md`** - Database operations
- Local API usage
- Query operators
- Complex queries with AND/OR
- Performance optimization
10. **`endpoints.md`** - Custom API endpoints
- REST endpoint patterns
- Authentication in endpoints
- Error handling
- Route parameters
11. **`adapters.md`** - Database and storage adapters
- MongoDB, PostgreSQL, SQLite patterns
- Storage adapter usage (S3, Azure, GCS, etc.)
- Custom adapter development
12. **`plugin-development.md`** - Creating plugins
- Plugin architecture
- Modifying configuration
- Plugin hooks
- Best practices
13. **`components.md`** - Custom Components
- Component types (Root, Collection, Global, Field)
- Server vs Client Components
- Component paths and definition

View file

@ -32,10 +32,49 @@ Teqo then becomes a configurable base product for other politicians in Brazil:
## Local Development
1. Copy environment variables: `cp .env.example .env`
2. Install dependencies: `pnpm install`
3. Start development server: `pnpm dev`
4. Open: `http://localhost:3000`
> **Production safety:** the production database is a live Neon Postgres holding real citizens' data. Local development and tests run against a **local** Postgres and must never point at production. `pnpm dev` refuses to start against a non-local database, and the test suite refuses any database whose name doesn't end in `_test`.
### First-time setup
1. Install dependencies: `pnpm install`
2. Create your env file: `cp .env.example .env.local`
3. Start Docker, then start the local database: `pnpm db:start`
- This runs Postgres 17 (matching production) and auto-creates a `teqo_test` database for the test suite.
4. Make sure `DATABASE_URL` in `.env.local` points at local:
```
DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo
```
5. Load a schema + content into the local database — pick one:
- **Mirror production content (recommended):** `PROD_DATABASE_URL="<unpooled Neon URL>" pnpm db:pull`
Copies production's schema and content into local. It only reads production and **excludes supporter PII** (`contact`/`signature`/`subscription` data).
- **Empty schema from migrations:** `pnpm migrate`
6. Start the dev server: `pnpm dev`
7. Open: `http://localhost:3000`
Useful scripts: `pnpm db:start` / `pnpm db:stop` (local Postgres), `pnpm db:pull` (refresh local content from prod).
### Database migrations
Schema is managed by committed Payload migrations (`src/migrations/`); `push` is disabled everywhere.
To change the schema:
1. Edit the collection/global/field config.
2. Generate a migration: `pnpm migrate:create <name>`
3. Review and commit the generated files in `src/migrations/` (both `.ts` and `.json`, plus `index.ts`).
4. Apply it locally: `pnpm migrate` (check status anytime with `pnpm migrate:status`).
**Deploying to production:** `pnpm build` runs `payload migrate` before building, so every Vercel deploy automatically applies pending migrations to the production database. Do not run migrations against production by hand.
### Running tests
Tests use the isolated `teqo_test` database (config in `.env.test`). Prepare its schema once with:
```
DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate
```
Then run `pnpm test` (or `pnpm test:int` / `pnpm test:e2e`).
## Tech Stack

View file

@ -1,43 +1,28 @@
version: '3'
services:
payload:
image: node:18-alpine
# Local development database. This is the ONLY database local dev and the
# test suite should ever touch — never point DATABASE_URL at the production
# Neon instance for local work (see the guard in scripts/guard-dev-db.mjs).
postgres:
# Pinned to match the production Neon major version (17), so pg_dump/restore
# from prod (see `pnpm db:pull`) works without version mismatches.
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo
ports:
- '3000:3000'
- '5432:5432'
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
working_dir: /home/node/app/
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
depends_on:
- mongo
# - postgres
env_file:
- .env
# Ensure your DATABASE_URL uses 'mongo' as the hostname ie. mongodb://mongo/my-db-name
mongo:
image: mongo:latest
ports:
- '27017:27017'
command:
- --storageEngine=wiredTiger
volumes:
- data:/data/db
logging:
driver: none
# Uncomment the following to use postgres
# postgres:
# restart: always
# image: postgres:latest
# volumes:
# - pgdata:/var/lib/postgresql/data
# ports:
# - "5432:5432"
- pgdata:/var/lib/postgresql/data
# Runs only on first initialization of the volume. Creates the dedicated
# teqo_test database so automated tests never share state with dev data.
- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U teqo -d teqo']
interval: 5s
timeout: 5s
retries: 10
volumes:
data:
# pgdata:
node_modules:
pgdata:

View file

@ -0,0 +1,5 @@
-- Runs once, on first initialization of the Postgres data volume.
-- Creates the dedicated database used by the automated test suite so that
-- tests (which delete and recreate records) never share state with local
-- development data — and can never be pointed at production.
CREATE DATABASE teqo_test;

View file

@ -21,11 +21,17 @@
"license": "MIT",
"type": "module",
"scripts": {
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
"build": "cross-env NODE_OPTIONS=--no-deprecation payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
"db:start": "docker compose up -d postgres",
"db:stop": "docker compose down",
"db:pull": "node scripts/db-pull.mjs",
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"migrate": "cross-env NODE_OPTIONS=--no-deprecation payload migrate",
"migrate:create": "cross-env NODE_OPTIONS=--no-deprecation payload migrate:create",
"migrate:status": "cross-env NODE_OPTIONS=--no-deprecation payload migrate:status",
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint --cache --cache-location .next/cache/eslint/",
"lint:fix": "cross-env NODE_OPTIONS=--no-deprecation eslint --fix",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",

View file

@ -1,10 +1,15 @@
import { defineConfig, devices } from '@playwright/test'
import { config as loadEnv } from 'dotenv'
import { assertTestDatabase } from './tests/helpers/assertTestDatabase'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
* e2e tests boot a real dev server that seeds/deletes records, so they must run
* against the isolated test database — never .env / .env.local (production).
* `override: true` guarantees the test DB wins over anything in the shell.
*/
import 'dotenv/config'
loadEnv({ path: '.env.test', override: true })
assertTestDatabase(process.env.DATABASE_URL)
/**
* See https://playwright.dev/docs/test-configuration.
@ -35,7 +40,17 @@ export default defineConfig({
],
webServer: {
command: 'pnpm dev',
reuseExistingServer: true,
/*
* Never reuse a dev server a developer may already have running against the
* production database — always start a fresh one bound to the test DB.
*/
reuseExistingServer: false,
url: 'http://localhost:3000',
/* Force the isolated test database into the server process. */
env: {
DATABASE_URL: process.env.DATABASE_URL as string,
PAYLOAD_SECRET: process.env.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production',
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000',
},
},
})

145
scripts/db-pull.mjs Normal file
View file

@ -0,0 +1,145 @@
/**
* Pulls a CONTENT-ONLY copy of the production database into the local Postgres.
*
* Safety model:
* - Source (production) is only ever READ, via `pg_dump`. This script issues
* no writes against production and cannot: it never even opens a writable
* connection to the source.
* - Target must be a LOCAL host (same guard as the dev server). The script
* refuses to restore into anything that isn't localhost.
* - PII is excluded: the data of the `contact`, `signature` and `subscription`
* tables (real supporters' names, e-mails, phones, CEP) is NOT copied. Their
* table structure is kept so the app still works; only the rows are omitted.
*
* Usage:
* PROD_DATABASE_URL=postgres://... pnpm db:pull
*
* PROD_DATABASE_URL should be the UNPOOLED Neon connection string (the
* DATABASE_URL_UNPOOLED value in .env.local).
*/
import { execFileSync } from 'node:child_process'
const PG_IMAGE = 'postgres:17-alpine' // must match the prod major version
const PII_TABLE_REGEX = '^(contact|signature|subscription)(_|$)'
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1'])
const prodUrl = process.env.PROD_DATABASE_URL
const localUrl = process.env.LOCAL_DATABASE_URL ?? 'postgresql://teqo:teqo@localhost:5432/teqo'
const die = (message) => {
console.error(`\n[db:pull] ${message}\n`)
process.exit(1)
}
if (!prodUrl) {
die(
'PROD_DATABASE_URL is not set.\n' +
'Run it with your UNPOOLED Neon URL (DATABASE_URL_UNPOOLED in .env.local):\n' +
' PROD_DATABASE_URL="postgres://...neon.tech/neondb?sslmode=require" pnpm db:pull',
)
}
let targetHost
try {
targetHost = new URL(localUrl).hostname
} catch {
die('LOCAL_DATABASE_URL is not a valid connection string.')
}
if (!LOCAL_HOSTS.has(targetHost)) {
die(`Refusing to restore into non-local host "${targetHost}". This script only writes to local.`)
}
// The local Postgres runs as the docker-compose "postgres" service. We run all
// restore commands inside that container so we don't depend on a local psql.
const containerId = execFileSync('docker', ['compose', 'ps', '-q', 'postgres'], {
encoding: 'utf8',
})
.trim()
.split('\n')[0]
if (!containerId) {
die('Local Postgres container is not running. Start it with `pnpm db:start` first.')
}
const targetUrl = new URL(localUrl)
const targetDb = targetUrl.pathname.replace(/^\//, '')
const runInPgImage = (shellCommand) =>
execFileSync(
'docker',
['run', '--rm', '-e', `PGURL=${prodUrl}`, PG_IMAGE, 'sh', '-c', shellCommand],
{
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 512,
},
)
console.log(`[db:pull] Source (read-only): ${new URL(prodUrl).hostname}`)
console.log(`[db:pull] Target (local): ${targetHost}/${targetDb}`)
// 1. Discover PII tables to exclude (contact/signature/subscription and their
// relationship/locale tables), directly from the production catalog.
console.log('[db:pull] Discovering PII tables to exclude...')
const piiTables = runInPgImage(
`psql "$PGURL" -tAc "SELECT table_name FROM information_schema.tables ` +
`WHERE table_schema='public' AND table_name ~ '${PII_TABLE_REGEX}' ORDER BY table_name;"`,
)
.trim()
.split('\n')
.filter(Boolean)
console.log(`[db:pull] Excluding data from: ${piiTables.join(', ') || '(none found)'}`)
const excludeArgs = [
...piiTables.map((t) => `--exclude-table-data='public.${t}'`),
// Never carry prod's migration tracking into local.
`--exclude-table='public.payload_migrations'`,
`--exclude-table='public.payload_migrations_id_seq'`,
].join(' ')
// 2. Dump prod (schema for everything + data for non-PII tables), plain SQL.
console.log('[db:pull] Dumping production (this reads prod, never writes)...')
const dumpSql = runInPgImage(`pg_dump "$PGURL" --no-owner --no-privileges ${excludeArgs}`)
// 3. Reset the local schema and restore into it.
console.log('[db:pull] Resetting local schema...')
execFileSync(
'docker',
[
'exec',
'-i',
containerId,
'psql',
'-U',
'teqo',
'-d',
targetDb,
'-v',
'ON_ERROR_STOP=1',
'-c',
'DROP SCHEMA public CASCADE; CREATE SCHEMA public;',
],
{ stdio: ['ignore', 'inherit', 'inherit'] },
)
console.log('[db:pull] Restoring into local...')
execFileSync(
'docker',
[
'exec',
'-i',
containerId,
'psql',
'-U',
'teqo',
'-d',
targetDb,
'-v',
'ON_ERROR_STOP=1',
'-f',
'-',
],
{ input: dumpSql, stdio: ['pipe', 'inherit', 'inherit'], maxBuffer: 1024 * 1024 * 512 },
)
console.log('\n[db:pull] Done. Local now mirrors production content (without supporter PII).')

57
scripts/guard-dev-db.mjs Normal file
View file

@ -0,0 +1,57 @@
/**
* Refuses to start the local dev server against a non-local database.
*
* Local development must use the Postgres from docker-compose. Pointing dev at
* the production Neon database (as .env / .env.local currently do) risks editing
* or deleting real data through the admin panel. This guard fails closed.
*
* Escape hatch (use with care): ALLOW_REMOTE_DB=true pnpm dev
*/
import { config as loadEnv } from 'dotenv'
const OVERRIDE_FLAG = 'ALLOW_REMOTE_DB'
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres'])
// Mirror Next.js precedence (.env.local wins over .env) WITHOUT overriding a
// DATABASE_URL that is already set in the real environment (e.g. injected by
// Playwright's webServer). dotenv's default `override: false` guarantees this.
loadEnv({ path: '.env.local' })
loadEnv({ path: '.env' })
const fail = (message) => {
console.error(`\n[guard-dev-db] ${message}\n`)
process.exit(1)
}
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
fail('DATABASE_URL is not set. Refusing to start the dev server.')
}
if (process.env[OVERRIDE_FLAG] === 'true' || process.env[OVERRIDE_FLAG] === '1') {
console.warn(
`\n[guard-dev-db] ${OVERRIDE_FLAG} is set — connecting to a remote database on purpose. Be careful.\n`,
)
process.exit(0)
}
let host
try {
host = new URL(databaseUrl).hostname
} catch {
fail('DATABASE_URL is not a valid connection string.')
}
if (!LOCAL_HOSTS.has(host)) {
fail(
`DATABASE_URL points at a non-local host ("${host}").\n` +
'This is almost certainly the production or a shared database.\n\n' +
'Local development should use the local Postgres from docker-compose:\n' +
' 1. pnpm db:start\n' +
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local\n\n' +
`If you REALLY mean to connect to a remote database, re-run with:\n ${OVERRIDE_FLAG}=true pnpm dev`,
)
}
console.log(`[guard-dev-db] OK — using local database host "${host}".`)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,415 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_petition_status" AS ENUM('draft', 'published');
CREATE TYPE "public"."enum__petition_v_version_status" AS ENUM('draft', 'published');
CREATE TYPE "public"."enum_contact_state" AS ENUM('AC', 'AL', 'AM', 'AP', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MG', 'MS', 'MT', 'PA', 'PB', 'PE', 'PI', 'PR', 'RJ', 'RN', 'RO', 'RR', 'RS', 'SC', 'SE', 'SP', 'TO');
CREATE TYPE "public"."enum_payload_jobs_log_task_slug" AS ENUM('inline', 'schedulePublish');
CREATE TYPE "public"."enum_payload_jobs_log_state" AS ENUM('failed', 'succeeded');
CREATE TYPE "public"."enum_payload_jobs_task_slug" AS ENUM('inline', 'schedulePublish');
CREATE TYPE "public"."enum_site_settings_social_links_platform" AS ENUM('instagram', 'facebook', 'youtube', 'whatsapp');
CREATE TYPE "public"."enum_site_settings_status" AS ENUM('draft', 'published');
CREATE TYPE "public"."enum__site_settings_v_version_social_links_platform" AS ENUM('instagram', 'facebook', 'youtube', 'whatsapp');
CREATE TYPE "public"."enum__site_settings_v_version_status" AS ENUM('draft', 'published');
CREATE TABLE "users_sessions" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"created_at" timestamp(3) with time zone,
"expires_at" timestamp(3) with time zone NOT NULL
);
CREATE TABLE "users" (
"id" serial PRIMARY KEY NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"email" varchar NOT NULL,
"reset_password_token" varchar,
"reset_password_expiration" timestamp(3) with time zone,
"salt" varchar,
"hash" varchar,
"login_attempts" numeric DEFAULT 0,
"lock_until" timestamp(3) with time zone
);
CREATE TABLE "media" (
"id" serial PRIMARY KEY NOT NULL,
"alt" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric
);
CREATE TABLE "petition" (
"id" varchar PRIMARY KEY NOT NULL,
"title" varchar,
"subtitle" varchar,
"enabled" boolean DEFAULT false,
"body" jsonb,
"form_title" varchar,
"form_subtitle" varchar,
"form_consent_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"_status" "enum_petition_status" DEFAULT 'draft'
);
CREATE TABLE "_petition_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" varchar,
"version_title" varchar,
"version_subtitle" varchar,
"version_enabled" boolean DEFAULT false,
"version_body" jsonb,
"version_form_title" varchar,
"version_form_subtitle" varchar,
"version_form_consent_id" integer,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"version__status" "enum__petition_v_version_status" DEFAULT 'draft',
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean
);
CREATE TABLE "contact" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"email" varchar NOT NULL,
"phone" varchar NOT NULL,
"state" "enum_contact_state" NOT NULL,
"city" varchar NOT NULL,
"postal_code" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "consent" (
"id" serial PRIMARY KEY NOT NULL,
"text" jsonb NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "signature" (
"id" serial PRIMARY KEY NOT NULL,
"contact_id" integer NOT NULL,
"petition_id" varchar NOT NULL,
"consent_id" integer NOT NULL,
"comment" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "subscription" (
"id" serial PRIMARY KEY NOT NULL,
"contact_id" integer NOT NULL,
"consent_id" integer NOT NULL,
"comment" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload_kv" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar NOT NULL,
"data" jsonb NOT NULL
);
CREATE TABLE "payload_jobs_log" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"executed_at" timestamp(3) with time zone NOT NULL,
"completed_at" timestamp(3) with time zone NOT NULL,
"task_slug" "enum_payload_jobs_log_task_slug" NOT NULL,
"task_i_d" varchar NOT NULL,
"input" jsonb,
"output" jsonb,
"state" "enum_payload_jobs_log_state" NOT NULL,
"error" jsonb
);
CREATE TABLE "payload_jobs" (
"id" serial PRIMARY KEY NOT NULL,
"input" jsonb,
"completed_at" timestamp(3) with time zone,
"total_tried" numeric DEFAULT 0,
"has_error" boolean DEFAULT false,
"error" jsonb,
"task_slug" "enum_payload_jobs_task_slug",
"queue" varchar DEFAULT 'default',
"wait_until" timestamp(3) with time zone,
"processing" boolean DEFAULT false,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload_locked_documents" (
"id" serial PRIMARY KEY NOT NULL,
"global_slug" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload_locked_documents_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer,
"media_id" integer,
"petition_id" varchar,
"contact_id" integer,
"consent_id" integer,
"signature_id" integer,
"subscription_id" integer
);
CREATE TABLE "payload_preferences" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar,
"value" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload_preferences_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer
);
CREATE TABLE "payload_migrations" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar,
"batch" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "site_settings_social_links" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"platform" "enum_site_settings_social_links_platform",
"url" varchar,
"label" varchar,
"enabled" boolean
);
CREATE TABLE "site_settings" (
"id" serial PRIMARY KEY NOT NULL,
"header_title" varchar,
"_status" "enum_site_settings_status" DEFAULT 'draft',
"updated_at" timestamp(3) with time zone,
"created_at" timestamp(3) with time zone
);
CREATE TABLE "_site_settings_v_version_social_links" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"platform" "enum__site_settings_v_version_social_links_platform",
"url" varchar,
"label" varchar,
"enabled" boolean,
"_uuid" varchar
);
CREATE TABLE "_site_settings_v" (
"id" serial PRIMARY KEY NOT NULL,
"version_header_title" varchar,
"version__status" "enum__site_settings_v_version_status" DEFAULT 'draft',
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean,
"autosave" boolean
);
CREATE TABLE "home" (
"id" serial PRIMARY KEY NOT NULL,
"image_id" integer NOT NULL,
"updated_at" timestamp(3) with time zone,
"created_at" timestamp(3) with time zone
);
CREATE TABLE "metadata_keywords" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"keyword" varchar NOT NULL
);
CREATE TABLE "metadata" (
"id" serial PRIMARY KEY NOT NULL,
"url" varchar NOT NULL,
"title" varchar NOT NULL,
"description" varchar NOT NULL,
"open_graph_site_name" varchar NOT NULL,
"open_graph_description" varchar NOT NULL,
"twitter_creator" varchar NOT NULL,
"twitter_description" varchar NOT NULL,
"image_id" integer,
"updated_at" timestamp(3) with time zone,
"created_at" timestamp(3) with time zone
);
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "petition" ADD CONSTRAINT "petition_form_consent_id_consent_id_fk" FOREIGN KEY ("form_consent_id") REFERENCES "public"."consent"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_petition_v" ADD CONSTRAINT "_petition_v_parent_id_petition_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."petition"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_petition_v" ADD CONSTRAINT "_petition_v_version_form_consent_id_consent_id_fk" FOREIGN KEY ("version_form_consent_id") REFERENCES "public"."consent"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "signature" ADD CONSTRAINT "signature_contact_id_contact_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "signature" ADD CONSTRAINT "signature_petition_id_petition_id_fk" FOREIGN KEY ("petition_id") REFERENCES "public"."petition"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "signature" ADD CONSTRAINT "signature_consent_id_consent_id_fk" FOREIGN KEY ("consent_id") REFERENCES "public"."consent"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_contact_id_contact_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_consent_id_consent_id_fk" FOREIGN KEY ("consent_id") REFERENCES "public"."consent"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload_jobs_log" ADD CONSTRAINT "payload_jobs_log_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."payload_jobs"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_petition_fk" FOREIGN KEY ("petition_id") REFERENCES "public"."petition"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_contact_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_consent_fk" FOREIGN KEY ("consent_id") REFERENCES "public"."consent"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_signature_fk" FOREIGN KEY ("signature_id") REFERENCES "public"."signature"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_subscription_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."subscription"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "site_settings_social_links" ADD CONSTRAINT "site_settings_social_links_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."site_settings"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_site_settings_v_version_social_links" ADD CONSTRAINT "_site_settings_v_version_social_links_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_site_settings_v"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "home" ADD CONSTRAINT "home_image_id_media_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "metadata_keywords" ADD CONSTRAINT "metadata_keywords_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."metadata"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "metadata" ADD CONSTRAINT "metadata_image_id_media_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
CREATE INDEX "petition_form_form_consent_idx" ON "petition" USING btree ("form_consent_id");
CREATE INDEX "petition_updated_at_idx" ON "petition" USING btree ("updated_at");
CREATE INDEX "petition_created_at_idx" ON "petition" USING btree ("created_at");
CREATE INDEX "petition__status_idx" ON "petition" USING btree ("_status");
CREATE INDEX "_petition_v_parent_idx" ON "_petition_v" USING btree ("parent_id");
CREATE INDEX "_petition_v_version_form_version_form_consent_idx" ON "_petition_v" USING btree ("version_form_consent_id");
CREATE INDEX "_petition_v_version_version_updated_at_idx" ON "_petition_v" USING btree ("version_updated_at");
CREATE INDEX "_petition_v_version_version_created_at_idx" ON "_petition_v" USING btree ("version_created_at");
CREATE INDEX "_petition_v_version_version__status_idx" ON "_petition_v" USING btree ("version__status");
CREATE INDEX "_petition_v_created_at_idx" ON "_petition_v" USING btree ("created_at");
CREATE INDEX "_petition_v_updated_at_idx" ON "_petition_v" USING btree ("updated_at");
CREATE INDEX "_petition_v_latest_idx" ON "_petition_v" USING btree ("latest");
CREATE INDEX "contact_updated_at_idx" ON "contact" USING btree ("updated_at");
CREATE INDEX "contact_created_at_idx" ON "contact" USING btree ("created_at");
CREATE INDEX "consent_updated_at_idx" ON "consent" USING btree ("updated_at");
CREATE INDEX "consent_created_at_idx" ON "consent" USING btree ("created_at");
CREATE INDEX "signature_contact_idx" ON "signature" USING btree ("contact_id");
CREATE INDEX "signature_petition_idx" ON "signature" USING btree ("petition_id");
CREATE INDEX "signature_consent_idx" ON "signature" USING btree ("consent_id");
CREATE INDEX "signature_updated_at_idx" ON "signature" USING btree ("updated_at");
CREATE INDEX "signature_created_at_idx" ON "signature" USING btree ("created_at");
CREATE INDEX "subscription_contact_idx" ON "subscription" USING btree ("contact_id");
CREATE INDEX "subscription_consent_idx" ON "subscription" USING btree ("consent_id");
CREATE INDEX "subscription_updated_at_idx" ON "subscription" USING btree ("updated_at");
CREATE INDEX "subscription_created_at_idx" ON "subscription" USING btree ("created_at");
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
CREATE INDEX "payload_jobs_log_order_idx" ON "payload_jobs_log" USING btree ("_order");
CREATE INDEX "payload_jobs_log_parent_id_idx" ON "payload_jobs_log" USING btree ("_parent_id");
CREATE INDEX "payload_jobs_completed_at_idx" ON "payload_jobs" USING btree ("completed_at");
CREATE INDEX "payload_jobs_total_tried_idx" ON "payload_jobs" USING btree ("total_tried");
CREATE INDEX "payload_jobs_has_error_idx" ON "payload_jobs" USING btree ("has_error");
CREATE INDEX "payload_jobs_task_slug_idx" ON "payload_jobs" USING btree ("task_slug");
CREATE INDEX "payload_jobs_queue_idx" ON "payload_jobs" USING btree ("queue");
CREATE INDEX "payload_jobs_wait_until_idx" ON "payload_jobs" USING btree ("wait_until");
CREATE INDEX "payload_jobs_processing_idx" ON "payload_jobs" USING btree ("processing");
CREATE INDEX "payload_jobs_updated_at_idx" ON "payload_jobs" USING btree ("updated_at");
CREATE INDEX "payload_jobs_created_at_idx" ON "payload_jobs" USING btree ("created_at");
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
CREATE INDEX "payload_locked_documents_rels_petition_id_idx" ON "payload_locked_documents_rels" USING btree ("petition_id");
CREATE INDEX "payload_locked_documents_rels_contact_id_idx" ON "payload_locked_documents_rels" USING btree ("contact_id");
CREATE INDEX "payload_locked_documents_rels_consent_id_idx" ON "payload_locked_documents_rels" USING btree ("consent_id");
CREATE INDEX "payload_locked_documents_rels_signature_id_idx" ON "payload_locked_documents_rels" USING btree ("signature_id");
CREATE INDEX "payload_locked_documents_rels_subscription_id_idx" ON "payload_locked_documents_rels" USING btree ("subscription_id");
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");
CREATE INDEX "site_settings_social_links_order_idx" ON "site_settings_social_links" USING btree ("_order");
CREATE INDEX "site_settings_social_links_parent_id_idx" ON "site_settings_social_links" USING btree ("_parent_id");
CREATE INDEX "site_settings__status_idx" ON "site_settings" USING btree ("_status");
CREATE INDEX "_site_settings_v_version_social_links_order_idx" ON "_site_settings_v_version_social_links" USING btree ("_order");
CREATE INDEX "_site_settings_v_version_social_links_parent_id_idx" ON "_site_settings_v_version_social_links" USING btree ("_parent_id");
CREATE INDEX "_site_settings_v_version_version__status_idx" ON "_site_settings_v" USING btree ("version__status");
CREATE INDEX "_site_settings_v_created_at_idx" ON "_site_settings_v" USING btree ("created_at");
CREATE INDEX "_site_settings_v_updated_at_idx" ON "_site_settings_v" USING btree ("updated_at");
CREATE INDEX "_site_settings_v_latest_idx" ON "_site_settings_v" USING btree ("latest");
CREATE INDEX "_site_settings_v_autosave_idx" ON "_site_settings_v" USING btree ("autosave");
CREATE INDEX "home_image_idx" ON "home" USING btree ("image_id");
CREATE INDEX "metadata_keywords_order_idx" ON "metadata_keywords" USING btree ("_order");
CREATE INDEX "metadata_keywords_parent_id_idx" ON "metadata_keywords" USING btree ("_parent_id");
CREATE INDEX "metadata_image_idx" ON "metadata" USING btree ("image_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "users_sessions" CASCADE;
DROP TABLE "users" CASCADE;
DROP TABLE "media" CASCADE;
DROP TABLE "petition" CASCADE;
DROP TABLE "_petition_v" CASCADE;
DROP TABLE "contact" CASCADE;
DROP TABLE "consent" CASCADE;
DROP TABLE "signature" CASCADE;
DROP TABLE "subscription" CASCADE;
DROP TABLE "payload_kv" CASCADE;
DROP TABLE "payload_jobs_log" CASCADE;
DROP TABLE "payload_jobs" CASCADE;
DROP TABLE "payload_locked_documents" CASCADE;
DROP TABLE "payload_locked_documents_rels" CASCADE;
DROP TABLE "payload_preferences" CASCADE;
DROP TABLE "payload_preferences_rels" CASCADE;
DROP TABLE "payload_migrations" CASCADE;
DROP TABLE "site_settings_social_links" CASCADE;
DROP TABLE "site_settings" CASCADE;
DROP TABLE "_site_settings_v_version_social_links" CASCADE;
DROP TABLE "_site_settings_v" CASCADE;
DROP TABLE "home" CASCADE;
DROP TABLE "metadata_keywords" CASCADE;
DROP TABLE "metadata" CASCADE;
DROP TYPE "public"."enum_petition_status";
DROP TYPE "public"."enum__petition_v_version_status";
DROP TYPE "public"."enum_contact_state";
DROP TYPE "public"."enum_payload_jobs_log_task_slug";
DROP TYPE "public"."enum_payload_jobs_log_state";
DROP TYPE "public"."enum_payload_jobs_task_slug";
DROP TYPE "public"."enum_site_settings_social_links_platform";
DROP TYPE "public"."enum_site_settings_status";
DROP TYPE "public"."enum__site_settings_v_version_social_links_platform";
DROP TYPE "public"."enum__site_settings_v_version_status";`)
}

View file

@ -0,0 +1,43 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
/**
* Reconciles production schema drift: `consent.text` is a `richText` field in the
* Payload config (which maps to `jsonb`), but the production database still has it
* as `varchar`. Its single row already contains valid Lexical JSON, so the cast is
* lossless. This migration is idempotent — it only runs the conversion when the
* column is still `varchar`, so it is a no-op on any database already at `jsonb`
* (e.g. a fresh local DB built from the initial migration).
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'consent'
AND column_name = 'text'
AND data_type = 'character varying'
) THEN
ALTER TABLE "consent" ALTER COLUMN "text" TYPE jsonb USING "text"::jsonb;
END IF;
END $$;
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'consent'
AND column_name = 'text'
AND data_type = 'jsonb'
) THEN
ALTER TABLE "consent" ALTER COLUMN "text" TYPE varchar USING "text"::text;
END IF;
END $$;
`)
}

15
src/migrations/index.ts Normal file
View file

@ -0,0 +1,15 @@
import * as migration_20260715_163458_initial from './20260715_163458_initial'
import * as migration_20260715_163500_consent_text_to_jsonb from './20260715_163500_consent_text_to_jsonb'
export const migrations = [
{
up: migration_20260715_163458_initial.up,
down: migration_20260715_163458_initial.down,
name: '20260715_163458_initial',
},
{
up: migration_20260715_163500_consent_text_to_jsonb.up,
down: migration_20260715_163500_consent_text_to_jsonb.down,
name: '20260715_163500_consent_text_to_jsonb',
},
]

View file

@ -2,21 +2,21 @@ import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
import { fileURLToPath } from 'url'
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { SiteSettings } from './globals/SiteSettings'
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
import { pt } from 'payload/i18n/pt'
import { Consent } from './collections/Consent'
import { Contact } from './collections/Contact'
import { Media } from './collections/Media'
import { Petition } from './collections/Petition'
import { Signature } from './collections/Signature'
import { Subscription } from './collections/Subscription'
import { Users } from './collections/Users'
import { HomePage } from './globals/HomePage'
import { Metadata } from './globals/Metadata'
import { Petition } from './collections/Petition'
import { Contact } from './collections/Contact'
import { Consent } from './collections/Consent'
import { Signature } from './collections/Signature'
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
import { Subscription } from './collections/Subscription'
import { SiteSettings } from './globals/SiteSettings'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@ -43,7 +43,10 @@ export default buildConfig({
pool: {
connectionString: process.env.DATABASE_URL || '',
},
// Local dev can point at the shared production DB; never auto-push schema changes.
// Schema changes are applied via committed migrations (pnpm migrate:create /
// pnpm migrate), never auto-pushed. `pnpm build` runs `payload migrate` before
// building, so migrations apply to prod on every Vercel deploy.
migrationDir: path.resolve(dirname, 'migrations'),
push: false,
}),
sharp,

View file

@ -0,0 +1,32 @@
/**
* Fail-closed guard for the automated test suite.
*
* The tests delete and recreate records (see seedUser.ts), so they must NEVER
* run against the production database. Rather than trying to detect prod (an
* allowlist we could forget to update), we require the opposite: the database
* name MUST end with `_test`. Anything else — including the production Neon
* database — is rejected.
*/
export function assertTestDatabase(databaseUrl: string | undefined): void {
if (!databaseUrl) {
throw new Error(
'DATABASE_URL is not set. Tests require a dedicated test database configured in .env.test ' +
'(e.g. postgresql://teqo:teqo@localhost:5432/teqo_test).',
)
}
let databaseName: string
try {
databaseName = new URL(databaseUrl).pathname.replace(/^\//, '')
} catch {
throw new Error('DATABASE_URL is not a valid PostgreSQL connection string.')
}
if (!/_test$/.test(databaseName)) {
throw new Error(
`Refusing to run tests against database "${databaseName || '(unknown)'}". ` +
'The test database name must end with "_test". ' +
'Configure DATABASE_URL in .env.test (e.g. postgresql://teqo:teqo@localhost:5432/teqo_test).',
)
}
}

View file

@ -1,6 +1,8 @@
import { getPayload } from 'payload'
import config from '../../src/payload.config.js'
import { assertTestDatabase } from './assertTestDatabase'
export const testUser = {
email: 'dev@payloadcms.com',
password: 'test',
@ -10,6 +12,7 @@ export const testUser = {
* Seeds a test user for e2e admin tests.
*/
export async function seedTestUser(): Promise<void> {
assertTestDatabase(process.env.DATABASE_URL)
const payload = await getPayload({ config })
// Delete existing test user if any
@ -33,6 +36,7 @@ export async function seedTestUser(): Promise<void> {
* Cleans up test user after tests
*/
export async function cleanupTestUser(): Promise<void> {
assertTestDatabase(process.env.DATABASE_URL)
const payload = await getPayload({ config })
await payload.delete({

View file

@ -1,4 +1,11 @@
// Any setup scripts you might need go here
// Load the isolated test environment — NEVER .env / .env.local, which point at
// the production database. `override: true` guarantees the test database wins
// even if a prod DATABASE_URL is already exported in the shell.
import { config as loadEnv } from 'dotenv'
// Load .env files
import 'dotenv/config'
import { assertTestDatabase } from './tests/helpers/assertTestDatabase'
loadEnv({ path: '.env.test', override: true })
// Fail closed: refuse to run the (destructive) suite against a non-test database.
assertTestDatabase(process.env.DATABASE_URL)