diff --git a/db/README.md b/db/README.md
index 483ee1a..b2eb715 100644
--- a/db/README.md
+++ b/db/README.md
@@ -1,28 +1,73 @@
-# Database migrations (Liquibase)
+# Base de datos (Postgres + Liquibase)
-Homologa el schema de cada ambiente con la misma cadena de changesets.
+PANELS usa **Postgres** organizado como monolito modular: dos bases de datos
+físicamente separadas en el mismo servidor/instancia por ambiente.
-## Bases
+| Base de datos | Esquema(s) | Changelog | Qué vive ahí |
+|---|---|---|---|
+| `panels_platform` | `public` | `db/platform/` | Control plane SaaS: `tenants`, `platform_users` (identidad de PANELS como operador), `smtp_settings`. Solo lo tocan las rutas `/v1/saas/*`. |
+| `panels_product` | `iam` | `db/iam/` | Identidad/roles/permisos **de cada tenant** (ej. usuarios de ARCTEC). Aislado de `core` -- sin JOIN/FK cruzado. |
+| `panels_product` | `core` | `db/core/` | Negocio: empresas, personal, obras, expedientes, presupuesto, nómina, gafetes. |
-| Archivo | Changelog |
-|---------|-----------|
-| `data/app.db` | `db/app/` |
-| `data/platform.db` | `db/platform/` |
+No hay FK real entre `panels_platform` y `panels_product` (bases distintas),
+ni entre los esquemas `iam` y `core` (aislamiento a propósito). `tenant_id`
+es una referencia lógica validada en la capa de aplicación. Ver el plan de
+migración para el detalle de la arquitectura y las reglas del monolito
+modular.
-## Cómo aplicar
+## Aprovisionamiento (una vez por ambiente, antes de Liquibase)
-Requisitos: Java 17+.
+Ver [`db/provision/README.md`](provision/README.md): crea los 6 roles de
+Postgres (`panels_{platform,iam,core}_{owner,app}`), las 2 bases de datos,
+los esquemas `iam`/`core`, la extensión `citext`, y los usuarios ACL de
+Redis. Incluye `dev-local.sh` para reproducir todo esto en una máquina de
+desarrollo sin depender de Coolify, y `verify-isolation.sh` para confirmar
+que el aislamiento realmente se cumple.
+
+## Aplicar migraciones
+
+Requiere Java 17+ y las credenciales del rol `_owner` de cada módulo (nunca
+`_app`, que es solo runtime):
```bash
-# descarga CLI + JDBC (una vez) y aplica changesets
-./db/update.sh all
-# o solo una
-./db/update.sh app
+# Local, después de correr db/provision/dev-local.sh:
+set -a && source .env.dev-local && set +a
+./db/update.sh all --context-filter=dev # dev: incluye datos de demo
+./db/update.sh all # staging/producción: solo esquema + catálogos
+
+# Un solo módulo
+./db/update.sh core
+./db/update.sh iam
./db/update.sh platform
```
-También: `npm run db:migrate` o al arrancar la API (`runLiquibase()` en Deno).
+`npm run db:migrate` corre `./db/update.sh all`. **Las migraciones ya NO
+corren automáticamente al arrancar la API** -- son un paso explícito de
+deploy (a diferencia del `runLiquibase()` que existía con SQLite).
-Nuevos cambios de schema → nuevo changeset en `db/app/changesets/` o `db/platform/changesets/` y referenciarlo en el `changelog-master.xml` correspondiente. No añadir migraciones en `api/db.ts`.
+Contexts de Liquibase:
+- Sin `context`: changesets de esquema y catálogos requeridos (`risk_levels`,
+ `document_types`, etc.) -- corren en **todos** los ambientes.
+- `context="dev"`: datos de demostración (tenant/empresas/proyecto ARCTEC) --
+ **nunca** en staging/producción. Siempre pasar `--context-filter`
+ explícito; no confiar en el default de Liquibase.
-Las herramientas viven en `db/tools/` (gitignored); `bootstrap-tools.sh` las descarga.
+## Nuevos cambios de schema
+
+Un changeset nuevo en `db/{platform,iam,core}/changesets/`, referenciado en
+el `changelog-master.xml` correspondiente. Reglas:
+
+- Nunca editar un changeset ya aplicado en un ambiente compartido -- crear
+ uno nuevo.
+- Usar `dbms:postgresql` en vez de `context` para lógica específica de motor.
+- No añadir migraciones directamente en `api/*.ts`.
+- Las herramientas (Liquibase + driver JDBC de Postgres) viven en
+ `db/tools/` (gitignored); `bootstrap-tools.sh` las descarga.
+
+## Bootstrap del primer usuario administrador
+
+El `password_hash` (PBKDF2) no lo puede generar un changeset SQL. El primer
+`platform_admin` y el primer `tenant_admin` de un tenant nuevo se crean con
+`scripts/bootstrap-admin.ts` (ver Fase 4 del plan de migración), no con
+lógica de seed en el arranque de la API ni con datos hardcodeados en
+Liquibase.
diff --git a/db/app/changelog-master.xml b/db/app/changelog-master.xml
deleted file mode 100644
index 0982bef..0000000
--- a/db/app/changelog-master.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/db/app/changesets/001-baseline.sql b/db/app/changesets/001-baseline.sql
deleted file mode 100644
index b1a4004..0000000
--- a/db/app/changesets/001-baseline.sql
+++ /dev/null
@@ -1,254 +0,0 @@
---liquibase formatted sql
-
---changeset panel:app-001-baseline endDelimiter:; splitStatements:true
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'
---comment: Baseline schema (skipped/MARK_RAN when legacy schema.sql already applied)
-
-PRAGMA foreign_keys = ON;
-
-CREATE TABLE IF NOT EXISTS users (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- username TEXT NOT NULL UNIQUE,
- password_hash TEXT NOT NULL,
- display_name TEXT NOT NULL,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
-CREATE TABLE IF NOT EXISTS companies (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- code TEXT NOT NULL UNIQUE,
- name TEXT NOT NULL,
- parent_id INTEGER REFERENCES companies(id),
- kind TEXT NOT NULL DEFAULT 'sub' CHECK (kind IN ('principal', 'sub')),
- status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
- registro_patronal TEXT NOT NULL DEFAULT '',
- razon_social TEXT NOT NULL DEFAULT '',
- nombre_comercial TEXT NOT NULL DEFAULT '',
- rfc TEXT NOT NULL DEFAULT '',
- regimen_fiscal TEXT NOT NULL DEFAULT '',
- clase_riesgo TEXT NOT NULL DEFAULT '',
- domicilio_fiscal TEXT NOT NULL DEFAULT '',
- codigo_postal TEXT NOT NULL DEFAULT '',
- ciudad TEXT NOT NULL DEFAULT '',
- estado TEXT NOT NULL DEFAULT '',
- telefono TEXT NOT NULL DEFAULT '',
- email TEXT NOT NULL DEFAULT '',
- representante_legal TEXT NOT NULL DEFAULT '',
- giro TEXT NOT NULL DEFAULT '',
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
-CREATE TABLE IF NOT EXISTS risk_levels (
- code TEXT PRIMARY KEY,
- label TEXT NOT NULL,
- color TEXT NOT NULL,
- text_color TEXT NOT NULL DEFAULT '#FFFFFF'
-);
-
-CREATE TABLE IF NOT EXISTS badge_themes (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- layout TEXT NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS projects (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- code TEXT NOT NULL UNIQUE,
- name TEXT NOT NULL,
- address TEXT NOT NULL DEFAULT '',
- stage TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'pausado', 'concluido', 'cancelado')),
- theme_id TEXT NOT NULL REFERENCES badge_themes(id),
- logo_left_path TEXT,
- logo_right_path TEXT,
- company_id INTEGER REFERENCES companies(id),
- contract_amount REAL,
- start_date TEXT,
- end_date TEXT,
- resident_name TEXT NOT NULL DEFAULT '',
- siroc TEXT NOT NULL DEFAULT '',
- payroll_tax_pct REAL NOT NULL DEFAULT 4,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
-CREATE TABLE IF NOT EXISTS workers (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- first_name TEXT NOT NULL,
- middle_name TEXT,
- last_name_p TEXT NOT NULL,
- last_name_m TEXT NOT NULL,
- curp TEXT NOT NULL COLLATE NOCASE,
- rfc TEXT NOT NULL COLLATE NOCASE,
- nss TEXT NOT NULL,
- phone TEXT NOT NULL,
- email TEXT NOT NULL,
- address TEXT NOT NULL,
- blood_type TEXT,
- hire_type TEXT NOT NULL,
- company_id INTEGER REFERENCES companies(id),
- position TEXT NOT NULL,
- risk_code TEXT NOT NULL REFERENCES risk_levels(code),
- work_type TEXT NOT NULL CHECK (work_type IN ('N', 'D')),
- daily_wage REAL NOT NULL DEFAULT 0,
- needs_badge INTEGER NOT NULL DEFAULT 1,
- status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'baja')),
- pipeline_status TEXT NOT NULL DEFAULT 'incompleto',
- imss_status TEXT NOT NULL DEFAULT 'sin_alta' CHECK (imss_status IN ('sin_alta', 'alta', 'baja_imss')),
- imss_company_id INTEGER REFERENCES companies(id),
- imss_alta_at TEXT,
- imss_baja_at TEXT,
- last_rehire_at TEXT,
- vcard_password_enc TEXT,
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_curp ON workers(curp);
-CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_rfc ON workers(rfc);
-CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_nss ON workers(nss);
-
-CREATE TABLE IF NOT EXISTS assignments (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
- project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
- active INTEGER NOT NULL DEFAULT 1,
- start_date TEXT NOT NULL,
- end_date TEXT,
- UNIQUE (worker_id, project_id)
-);
-
-CREATE TABLE IF NOT EXISTS document_types (
- code TEXT PRIMARY KEY,
- label TEXT NOT NULL,
- required INTEGER NOT NULL DEFAULT 1,
- validity_mode TEXT NOT NULL DEFAULT 'none' CHECK (validity_mode IN ('none', 'freshness', 'expiry')),
- freshness_days INTEGER,
- requires_issued_at INTEGER NOT NULL DEFAULT 0,
- requires_expires_at INTEGER NOT NULL DEFAULT 0
-);
-
-CREATE TABLE IF NOT EXISTS documents (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
- type_code TEXT NOT NULL REFERENCES document_types(code),
- original_name TEXT NOT NULL,
- mime TEXT NOT NULL,
- size_bytes INTEGER NOT NULL,
- sha256 TEXT NOT NULL,
- iv TEXT NOT NULL,
- storage_name TEXT NOT NULL,
- is_current INTEGER NOT NULL DEFAULT 1,
- parse_status TEXT NOT NULL DEFAULT 'manual',
- issued_at TEXT,
- expires_at TEXT,
- imss_company_id INTEGER REFERENCES companies(id),
- imss_alta_at TEXT,
- uploaded_by INTEGER REFERENCES users(id),
- uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
-CREATE TABLE IF NOT EXISTS project_document_types (
- code TEXT PRIMARY KEY,
- label TEXT NOT NULL,
- required INTEGER NOT NULL DEFAULT 1
-);
-
-CREATE TABLE IF NOT EXISTS project_documents (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
- type_code TEXT NOT NULL REFERENCES project_document_types(code),
- original_name TEXT NOT NULL,
- mime TEXT NOT NULL,
- size_bytes INTEGER NOT NULL,
- sha256 TEXT NOT NULL,
- iv TEXT NOT NULL,
- storage_name TEXT NOT NULL,
- is_current INTEGER NOT NULL DEFAULT 1,
- parse_status TEXT NOT NULL DEFAULT 'manual',
- uploaded_by INTEGER REFERENCES users(id),
- uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
-CREATE TABLE IF NOT EXISTS badge_jobs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- project_id INTEGER NOT NULL REFERENCES projects(id),
- status TEXT NOT NULL DEFAULT 'done',
- pdf_path TEXT,
- created_by INTEGER REFERENCES users(id),
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
-CREATE TABLE IF NOT EXISTS badge_job_people (
- job_id INTEGER NOT NULL REFERENCES badge_jobs(id) ON DELETE CASCADE,
- worker_id INTEGER NOT NULL REFERENCES workers(id),
- delivered INTEGER NOT NULL DEFAULT 0,
- delivered_at TEXT,
- PRIMARY KEY (job_id, worker_id)
-);
-
-CREATE TABLE IF NOT EXISTS loans (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
- amount REAL NOT NULL,
- balance REAL NOT NULL,
- weekly_payment REAL NOT NULL,
- note TEXT,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
-CREATE TABLE IF NOT EXISTS attendance (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
- project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
- work_date TEXT NOT NULL,
- present INTEGER NOT NULL DEFAULT 1,
- UNIQUE (worker_id, project_id, work_date)
-);
-
-CREATE TABLE IF NOT EXISTS payroll_periods (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- project_id INTEGER NOT NULL REFERENCES projects(id),
- week_start TEXT NOT NULL,
- week_end TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'draft',
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
-CREATE TABLE IF NOT EXISTS payroll_lines (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- period_id INTEGER NOT NULL REFERENCES payroll_periods(id) ON DELETE CASCADE,
- worker_id INTEGER NOT NULL REFERENCES workers(id),
- days REAL NOT NULL DEFAULT 0,
- daily_wage REAL NOT NULL,
- gross REAL NOT NULL,
- discounts REAL NOT NULL DEFAULT 0,
- loan_payment REAL NOT NULL DEFAULT 0,
- net REAL NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS budget_chapters (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
- parent_id INTEGER REFERENCES budget_chapters(id) ON DELETE SET NULL,
- code TEXT NOT NULL DEFAULT '',
- name TEXT NOT NULL,
- wbs TEXT NOT NULL DEFAULT '',
- sort_order INTEGER NOT NULL DEFAULT 0
-);
-
-CREATE TABLE IF NOT EXISTS budget_items (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
- chapter_id INTEGER REFERENCES budget_chapters(id) ON DELETE SET NULL,
- code TEXT NOT NULL DEFAULT '',
- description TEXT NOT NULL,
- unit TEXT NOT NULL DEFAULT '',
- quantity REAL NOT NULL DEFAULT 0,
- unit_price REAL NOT NULL DEFAULT 0,
- amount REAL NOT NULL DEFAULT 0,
- wbs TEXT NOT NULL DEFAULT '',
- sort_order INTEGER NOT NULL DEFAULT 0
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_code ON projects(code);
diff --git a/db/app/changesets/002-tenant-id.sql b/db/app/changesets/002-tenant-id.sql
deleted file mode 100644
index 9ad4792..0000000
--- a/db/app/changesets/002-tenant-id.sql
+++ /dev/null
@@ -1,43 +0,0 @@
---liquibase formatted sql
-
---changeset panel:app-002a-users-tenant endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='tenant_id'
-ALTER TABLE users ADD COLUMN tenant_id INTEGER;
-
---changeset panel:app-002b-users-role endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='role'
-ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
-
---changeset panel:app-002c-users-company endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='company_id'
-ALTER TABLE users ADD COLUMN company_id INTEGER REFERENCES companies(id);
-
---changeset panel:app-002d-companies-tenant endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('companies') WHERE name='tenant_id'
-ALTER TABLE companies ADD COLUMN tenant_id INTEGER;
-
---changeset panel:app-002e-workers-tenant endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('workers') WHERE name='tenant_id'
-ALTER TABLE workers ADD COLUMN tenant_id INTEGER;
-
---changeset panel:app-002f-projects-tenant endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('projects') WHERE name='tenant_id'
-ALTER TABLE projects ADD COLUMN tenant_id INTEGER;
-
---changeset panel:app-002g-backfill-tenant endDelimiter:; splitStatements:true
-UPDATE companies SET tenant_id = 1 WHERE tenant_id IS NULL;
-UPDATE workers SET tenant_id = 1 WHERE tenant_id IS NULL;
-UPDATE projects SET tenant_id = 1 WHERE tenant_id IS NULL;
-UPDATE users SET tenant_id = 1 WHERE tenant_id IS NULL;
-UPDATE users SET role = 'tenant_admin' WHERE COALESCE(role, 'user') = 'user';
-
-CREATE INDEX IF NOT EXISTS idx_companies_tenant ON companies(tenant_id);
-CREATE INDEX IF NOT EXISTS idx_workers_tenant ON workers(tenant_id);
-CREATE INDEX IF NOT EXISTS idx_projects_tenant ON projects(tenant_id);
-CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
diff --git a/db/app/changesets/003-must-change-password.sql b/db/app/changesets/003-must-change-password.sql
deleted file mode 100644
index 3f9c238..0000000
--- a/db/app/changesets/003-must-change-password.sql
+++ /dev/null
@@ -1,11 +0,0 @@
---liquibase formatted sql
-
---changeset panel:app-003a-must-change-password endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='must_change_password'
-ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0;
-
---changeset panel:app-003b-user-email endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='email'
-ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT '';
diff --git a/db/app/changesets/004-document-catalog.sql b/db/app/changesets/004-document-catalog.sql
deleted file mode 100644
index f2a18fe..0000000
--- a/db/app/changesets/004-document-catalog.sql
+++ /dev/null
@@ -1,40 +0,0 @@
---liquibase formatted sql
-
---changeset panel:app-004a-doc-type-category endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('document_types') WHERE name='category'
-ALTER TABLE document_types ADD COLUMN category TEXT NOT NULL DEFAULT 'identidad';
-
---changeset panel:app-004b-project-doc-category endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('project_document_types') WHERE name='category'
-ALTER TABLE project_document_types ADD COLUMN category TEXT NOT NULL DEFAULT 'contrato';
-
---changeset panel:app-004c-company-document-types endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='company_document_types'
-CREATE TABLE company_document_types (
- code TEXT PRIMARY KEY,
- label TEXT NOT NULL,
- required INTEGER NOT NULL DEFAULT 0,
- category TEXT NOT NULL DEFAULT 'otro'
-);
-
---changeset panel:app-004d-company-documents endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='company_documents'
-CREATE TABLE company_documents (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
- type_code TEXT NOT NULL REFERENCES company_document_types(code),
- original_name TEXT NOT NULL,
- mime TEXT NOT NULL,
- size_bytes INTEGER NOT NULL,
- sha256 TEXT NOT NULL,
- iv TEXT NOT NULL,
- storage_name TEXT NOT NULL,
- is_current INTEGER NOT NULL DEFAULT 1,
- parse_status TEXT NOT NULL DEFAULT 'manual',
- uploaded_by INTEGER REFERENCES users(id),
- uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
diff --git a/db/app/changesets/005-payroll-week.sql b/db/app/changesets/005-payroll-week.sql
deleted file mode 100644
index bee8761..0000000
--- a/db/app/changesets/005-payroll-week.sql
+++ /dev/null
@@ -1,164 +0,0 @@
---liquibase formatted sql
-
---changeset panel:app-005a-payroll-settings endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_settings'
-CREATE TABLE payroll_settings (
- tenant_id INTEGER NOT NULL PRIMARY KEY,
- loan_commission_enabled INTEGER NOT NULL DEFAULT 1,
- loan_commission_pct REAL NOT NULL DEFAULT 10,
- loan_small_max REAL NOT NULL DEFAULT 500
-);
-
---changeset panel:app-005b-payroll-weeks endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_weeks'
-CREATE TABLE payroll_weeks (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- week_start TEXT NOT NULL,
- week_end TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'assembled', 'paid')),
- required_net REAL NOT NULL DEFAULT 0,
- payable_net REAL NOT NULL DEFAULT 0,
- assembled_at TEXT,
- paid_at TEXT,
- tenant_id INTEGER NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-CREATE UNIQUE INDEX idx_payroll_weeks_tenant_start ON payroll_weeks(tenant_id, week_start);
-
---changeset panel:app-005c-payroll-sheets endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_sheets'
-CREATE TABLE payroll_sheets (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE,
- kind TEXT NOT NULL CHECK (kind IN ('obra', 'destajo', 'admin')),
- project_id INTEGER REFERENCES projects(id)
-);
-CREATE UNIQUE INDEX idx_payroll_sheets_week_kind_project ON payroll_sheets(week_id, kind, IFNULL(project_id, 0));
-
---changeset panel:app-005d-payroll-week-lines endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_week_lines'
-CREATE TABLE payroll_week_lines (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- sheet_id INTEGER NOT NULL REFERENCES payroll_sheets(id) ON DELETE CASCADE,
- worker_id INTEGER NOT NULL REFERENCES workers(id),
- destajo_cut_line_id INTEGER,
- project_id INTEGER REFERENCES projects(id),
- days REAL NOT NULL DEFAULT 0,
- daily_wage REAL NOT NULL DEFAULT 0,
- qty_planned REAL NOT NULL DEFAULT 0,
- qty_actual REAL NOT NULL DEFAULT 0,
- qty_extra REAL NOT NULL DEFAULT 0,
- unit_price REAL NOT NULL DEFAULT 0,
- unit_code TEXT,
- concepto TEXT,
- amount REAL NOT NULL DEFAULT 0,
- gross REAL NOT NULL DEFAULT 0,
- discounts REAL NOT NULL DEFAULT 0,
- loan_id INTEGER REFERENCES loans(id),
- loan_discount REAL NOT NULL DEFAULT 0,
- loan_label TEXT,
- required_net REAL NOT NULL DEFAULT 0,
- payable_net REAL NOT NULL DEFAULT 0
-);
-
---changeset panel:app-005e-destajo-units endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_units'
-CREATE TABLE destajo_units (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- tenant_id INTEGER NOT NULL DEFAULT 0,
- code TEXT NOT NULL,
- label TEXT NOT NULL,
- UNIQUE (tenant_id, code)
-);
-
---changeset panel:app-005f-destajo-periods endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_periods'
-CREATE TABLE destajo_periods (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- period_start TEXT NOT NULL,
- period_end TEXT NOT NULL,
- week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE,
- tenant_id INTEGER NOT NULL DEFAULT 0,
- UNIQUE (tenant_id, period_end)
-);
-
---changeset panel:app-005g-destajo-jobs endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_jobs'
-CREATE TABLE destajo_jobs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- project_id INTEGER NOT NULL REFERENCES projects(id),
- worker_id INTEGER NOT NULL REFERENCES workers(id),
- concepto TEXT NOT NULL,
- unit_code TEXT NOT NULL,
- qty_total_estimated REAL NOT NULL,
- unit_price REAL NOT NULL,
- status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'done')),
- tenant_id INTEGER NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
---changeset panel:app-005h-destajo-cut-lines endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_cut_lines'
-CREATE TABLE destajo_cut_lines (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- period_id INTEGER NOT NULL REFERENCES destajo_periods(id) ON DELETE CASCADE,
- job_id INTEGER NOT NULL REFERENCES destajo_jobs(id) ON DELETE CASCADE,
- qty_planned REAL NOT NULL DEFAULT 0,
- qty_actual REAL NOT NULL DEFAULT 0,
- qty_extra REAL NOT NULL DEFAULT 0,
- UNIQUE (period_id, job_id)
-);
-
---changeset panel:app-005i-loan-payments endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='loan_payments'
-CREATE TABLE loan_payments (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- loan_id INTEGER NOT NULL REFERENCES loans(id) ON DELETE CASCADE,
- week_id INTEGER NOT NULL REFERENCES payroll_weeks(id),
- amount REAL NOT NULL,
- installment_n INTEGER NOT NULL DEFAULT 1,
- label TEXT,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
---changeset panel:app-005j-loans-delivered endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='delivered'
-ALTER TABLE loans ADD COLUMN delivered REAL NOT NULL DEFAULT 0;
-
---changeset panel:app-005k-loans-commission-pct endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='commission_pct'
-ALTER TABLE loans ADD COLUMN commission_pct REAL NOT NULL DEFAULT 0;
-
---changeset panel:app-005l-loans-commission-amount endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='commission_amount'
-ALTER TABLE loans ADD COLUMN commission_amount REAL NOT NULL DEFAULT 0;
-
---changeset panel:app-005m-loans-plan endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='plan'
-ALTER TABLE loans ADD COLUMN plan TEXT NOT NULL DEFAULT 'single';
-
---changeset panel:app-005n-loans-installments endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='installments_n'
-ALTER TABLE loans ADD COLUMN installments_n INTEGER NOT NULL DEFAULT 1;
-
---changeset panel:app-005o-loans-first-due endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='first_due'
-ALTER TABLE loans ADD COLUMN first_due TEXT;
-
---changeset panel:app-005p-loans-migrate-delivered endDelimiter:;
-UPDATE loans SET delivered = amount WHERE delivered = 0 AND amount > 0;
diff --git a/db/app/liquibase.properties b/db/app/liquibase.properties
deleted file mode 100644
index aca1901..0000000
--- a/db/app/liquibase.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-changeLogFile=changelog-master.xml
-driver=org.sqlite.JDBC
-url=jdbc:sqlite:../../data/app.db
-classpath=../tools/sqlite-jdbc.jar
diff --git a/db/bootstrap-tools.sh b/db/bootstrap-tools.sh
index cd866d2..0acc2cf 100755
--- a/db/bootstrap-tools.sh
+++ b/db/bootstrap-tools.sh
@@ -13,10 +13,10 @@ if [[ ! -x "$TOOLS/liquibase/liquibase" ]]; then
rm -f liquibase.zip
fi
-if [[ ! -f "$TOOLS/sqlite-jdbc.jar" ]]; then
- echo "Downloading sqlite-jdbc..."
- curl -fsSL -o sqlite-jdbc.jar \
- "https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.46.1.3/sqlite-jdbc-3.46.1.3.jar"
+if [[ ! -f "$TOOLS/postgresql-jdbc.jar" ]]; then
+ echo "Downloading PostgreSQL JDBC driver..."
+ curl -fsSL -o postgresql-jdbc.jar \
+ "https://repo1.maven.org/maven2/org/postgresql/postgresql/42.7.4/postgresql-42.7.4.jar"
fi
echo "Liquibase tools ready."
diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml
new file mode 100644
index 0000000..0a661ea
--- /dev/null
+++ b/db/core/changelog-master.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/db/core/changesets/001-baseline.sql b/db/core/changesets/001-baseline.sql
new file mode 100644
index 0000000..309a4a2
--- /dev/null
+++ b/db/core/changesets/001-baseline.sql
@@ -0,0 +1,482 @@
+--liquibase formatted sql
+-- PANELS · panels_product.core (Postgres) · baseline
+--
+-- Negocio: empresas, personal, obras, expedientes, presupuesto, nómina,
+-- gafetes. Sin FK hacia iam (documents.uploaded_by / badge_jobs.created_by
+-- son snapshot desnormalizado, no referencia viva) ni hacia
+-- panels_platform.tenants (bases distintas -- tenant_id se valida en la
+-- capa de aplicación, igual que hoy entre app.db/platform.db).
+--
+-- Requiere que la extensión citext ya esté instalada en la base de datos
+-- (ver db/provision/04-product-database.sql, corre como superuser).
+
+--changeset panel:core-001a-companies endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='companies'
+CREATE TABLE core.companies (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ code TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ parent_id INTEGER REFERENCES core.companies(id),
+ kind TEXT NOT NULL DEFAULT 'sub' CHECK (kind IN ('principal', 'sub')),
+ status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
+ tenant_id INTEGER,
+ registro_patronal TEXT NOT NULL DEFAULT '',
+ razon_social TEXT NOT NULL DEFAULT '',
+ nombre_comercial TEXT NOT NULL DEFAULT '',
+ rfc TEXT NOT NULL DEFAULT '',
+ regimen_fiscal TEXT NOT NULL DEFAULT '',
+ clase_riesgo TEXT NOT NULL DEFAULT '',
+ domicilio_fiscal TEXT NOT NULL DEFAULT '',
+ codigo_postal TEXT NOT NULL DEFAULT '',
+ ciudad TEXT NOT NULL DEFAULT '',
+ estado TEXT NOT NULL DEFAULT '',
+ telefono TEXT NOT NULL DEFAULT '',
+ email TEXT NOT NULL DEFAULT '',
+ representante_legal TEXT NOT NULL DEFAULT '',
+ giro TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX idx_core_companies_tenant ON core.companies(tenant_id);
+
+--changeset panel:core-001b-risk-levels endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='risk_levels'
+CREATE TABLE core.risk_levels (
+ code TEXT PRIMARY KEY,
+ label TEXT NOT NULL,
+ color TEXT NOT NULL,
+ text_color TEXT NOT NULL DEFAULT '#FFFFFF'
+);
+
+--changeset panel:core-001c-badge-themes endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='badge_themes'
+CREATE TABLE core.badge_themes (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ layout TEXT NOT NULL
+);
+
+--changeset panel:core-001d-projects endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='projects'
+CREATE TABLE core.projects (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ code TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ address TEXT NOT NULL DEFAULT '',
+ stage TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'pausado', 'concluido', 'cancelado')),
+ theme_id TEXT NOT NULL REFERENCES core.badge_themes(id),
+ logo_left_path TEXT,
+ logo_right_path TEXT,
+ company_id INTEGER REFERENCES core.companies(id),
+ tenant_id INTEGER,
+ contract_amount NUMERIC(14,2),
+ start_date DATE,
+ end_date DATE,
+ resident_name TEXT NOT NULL DEFAULT '',
+ siroc TEXT NOT NULL DEFAULT '',
+ payroll_tax_pct NUMERIC(5,2) NOT NULL DEFAULT 4,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX idx_core_projects_tenant ON core.projects(tenant_id);
+
+--changeset panel:core-001e-workers endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='workers'
+CREATE TABLE core.workers (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ first_name TEXT NOT NULL,
+ middle_name TEXT,
+ last_name_p TEXT NOT NULL,
+ last_name_m TEXT NOT NULL,
+ curp CITEXT NOT NULL UNIQUE,
+ rfc CITEXT NOT NULL UNIQUE,
+ nss TEXT NOT NULL UNIQUE,
+ phone TEXT NOT NULL,
+ email TEXT NOT NULL,
+ address TEXT NOT NULL,
+ blood_type TEXT,
+ hire_type TEXT NOT NULL,
+ company_id INTEGER REFERENCES core.companies(id),
+ tenant_id INTEGER,
+ position TEXT NOT NULL,
+ risk_code TEXT NOT NULL REFERENCES core.risk_levels(code),
+ work_type TEXT NOT NULL CHECK (work_type IN ('N', 'D')),
+ daily_wage NUMERIC(12,2) NOT NULL DEFAULT 0,
+ needs_badge BOOLEAN NOT NULL DEFAULT true,
+ status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'baja')),
+ pipeline_status TEXT NOT NULL DEFAULT 'incompleto',
+ imss_status TEXT NOT NULL DEFAULT 'sin_alta' CHECK (imss_status IN ('sin_alta', 'alta', 'baja_imss')),
+ imss_company_id INTEGER REFERENCES core.companies(id),
+ imss_alta_at DATE,
+ imss_baja_at DATE,
+ last_rehire_at DATE,
+ vcard_password_enc TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX idx_core_workers_tenant ON core.workers(tenant_id);
+
+--changeset panel:core-001f-assignments endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='assignments'
+CREATE TABLE core.assignments (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ worker_id INTEGER NOT NULL REFERENCES core.workers(id) ON DELETE CASCADE,
+ project_id INTEGER NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
+ active BOOLEAN NOT NULL DEFAULT true,
+ start_date DATE NOT NULL,
+ end_date DATE,
+ UNIQUE (worker_id, project_id)
+);
+
+--changeset panel:core-001g-document-types endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='document_types'
+CREATE TABLE core.document_types (
+ code TEXT PRIMARY KEY,
+ label TEXT NOT NULL,
+ required BOOLEAN NOT NULL DEFAULT true,
+ validity_mode TEXT NOT NULL DEFAULT 'none' CHECK (validity_mode IN ('none', 'freshness', 'expiry')),
+ freshness_days INTEGER,
+ requires_issued_at BOOLEAN NOT NULL DEFAULT false,
+ requires_expires_at BOOLEAN NOT NULL DEFAULT false,
+ category TEXT NOT NULL DEFAULT 'identidad'
+);
+
+--changeset panel:core-001h-documents endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='documents'
+--comment: uploaded_by_id/uploaded_by_name son snapshot -- sin FK hacia
+-- iam.users (esquemas aislados a propósito).
+CREATE TABLE core.documents (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ worker_id INTEGER NOT NULL REFERENCES core.workers(id) ON DELETE CASCADE,
+ type_code TEXT NOT NULL REFERENCES core.document_types(code),
+ original_name TEXT NOT NULL,
+ mime TEXT NOT NULL,
+ size_bytes BIGINT NOT NULL,
+ sha256 TEXT NOT NULL,
+ iv TEXT NOT NULL,
+ storage_name TEXT NOT NULL,
+ is_current BOOLEAN NOT NULL DEFAULT true,
+ parse_status TEXT NOT NULL DEFAULT 'manual',
+ issued_at DATE,
+ expires_at DATE,
+ imss_company_id INTEGER REFERENCES core.companies(id),
+ imss_alta_at DATE,
+ uploaded_by_id INTEGER,
+ uploaded_by_name TEXT NOT NULL DEFAULT '',
+ uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+--changeset panel:core-001i-project-document-types endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='project_document_types'
+CREATE TABLE core.project_document_types (
+ code TEXT PRIMARY KEY,
+ label TEXT NOT NULL,
+ required BOOLEAN NOT NULL DEFAULT true,
+ category TEXT NOT NULL DEFAULT 'contrato'
+);
+
+--changeset panel:core-001j-project-documents endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='project_documents'
+CREATE TABLE core.project_documents (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ project_id INTEGER NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
+ type_code TEXT NOT NULL REFERENCES core.project_document_types(code),
+ original_name TEXT NOT NULL,
+ mime TEXT NOT NULL,
+ size_bytes BIGINT NOT NULL,
+ sha256 TEXT NOT NULL,
+ iv TEXT NOT NULL,
+ storage_name TEXT NOT NULL,
+ is_current BOOLEAN NOT NULL DEFAULT true,
+ parse_status TEXT NOT NULL DEFAULT 'manual',
+ uploaded_by_id INTEGER,
+ uploaded_by_name TEXT NOT NULL DEFAULT '',
+ uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+--changeset panel:core-001k-company-document-types endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='company_document_types'
+CREATE TABLE core.company_document_types (
+ code TEXT PRIMARY KEY,
+ label TEXT NOT NULL,
+ required BOOLEAN NOT NULL DEFAULT false,
+ category TEXT NOT NULL DEFAULT 'otro'
+);
+
+--changeset panel:core-001l-company-documents endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='company_documents'
+CREATE TABLE core.company_documents (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ company_id INTEGER NOT NULL REFERENCES core.companies(id) ON DELETE CASCADE,
+ type_code TEXT NOT NULL REFERENCES core.company_document_types(code),
+ original_name TEXT NOT NULL,
+ mime TEXT NOT NULL,
+ size_bytes BIGINT NOT NULL,
+ sha256 TEXT NOT NULL,
+ iv TEXT NOT NULL,
+ storage_name TEXT NOT NULL,
+ is_current BOOLEAN NOT NULL DEFAULT true,
+ parse_status TEXT NOT NULL DEFAULT 'manual',
+ uploaded_by_id INTEGER,
+ uploaded_by_name TEXT NOT NULL DEFAULT '',
+ uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+--changeset panel:core-001m-badge-jobs endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='badge_jobs'
+CREATE TABLE core.badge_jobs (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ project_id INTEGER NOT NULL REFERENCES core.projects(id),
+ status TEXT NOT NULL DEFAULT 'done',
+ pdf_path TEXT,
+ created_by_id INTEGER,
+ created_by_name TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+--changeset panel:core-001n-badge-job-people endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='badge_job_people'
+CREATE TABLE core.badge_job_people (
+ job_id INTEGER NOT NULL REFERENCES core.badge_jobs(id) ON DELETE CASCADE,
+ worker_id INTEGER NOT NULL REFERENCES core.workers(id),
+ delivered BOOLEAN NOT NULL DEFAULT false,
+ delivered_at TIMESTAMPTZ,
+ PRIMARY KEY (job_id, worker_id)
+);
+
+--changeset panel:core-001o-loans endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='loans'
+CREATE TABLE core.loans (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ worker_id INTEGER NOT NULL REFERENCES core.workers(id) ON DELETE CASCADE,
+ amount NUMERIC(12,2) NOT NULL,
+ delivered NUMERIC(12,2) NOT NULL DEFAULT 0,
+ balance NUMERIC(12,2) NOT NULL,
+ weekly_payment NUMERIC(12,2) NOT NULL,
+ note TEXT,
+ commission_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
+ commission_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
+ plan TEXT NOT NULL DEFAULT 'single',
+ installments_n INTEGER NOT NULL DEFAULT 1,
+ first_due DATE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+--changeset panel:core-001p-attendance endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='attendance'
+CREATE TABLE core.attendance (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ worker_id INTEGER NOT NULL REFERENCES core.workers(id) ON DELETE CASCADE,
+ project_id INTEGER NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
+ work_date DATE NOT NULL,
+ present BOOLEAN NOT NULL DEFAULT true,
+ UNIQUE (worker_id, project_id, work_date)
+);
+
+--changeset panel:core-001q-payroll-periods endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='payroll_periods'
+--comment: Nómina simple por proyecto (legacy, sigue en uso via payroll_http.ts).
+CREATE TABLE core.payroll_periods (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ project_id INTEGER NOT NULL REFERENCES core.projects(id),
+ week_start DATE NOT NULL,
+ week_end DATE NOT NULL,
+ status TEXT NOT NULL DEFAULT 'draft',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+--changeset panel:core-001r-payroll-lines endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='payroll_lines'
+CREATE TABLE core.payroll_lines (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ period_id INTEGER NOT NULL REFERENCES core.payroll_periods(id) ON DELETE CASCADE,
+ worker_id INTEGER NOT NULL REFERENCES core.workers(id),
+ days NUMERIC(5,2) NOT NULL DEFAULT 0,
+ daily_wage NUMERIC(12,2) NOT NULL,
+ gross NUMERIC(12,2) NOT NULL,
+ discounts NUMERIC(12,2) NOT NULL DEFAULT 0,
+ loan_payment NUMERIC(12,2) NOT NULL DEFAULT 0,
+ net NUMERIC(12,2) NOT NULL
+);
+
+--changeset panel:core-001s-payroll-settings endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='payroll_settings'
+CREATE TABLE core.payroll_settings (
+ tenant_id INTEGER PRIMARY KEY,
+ loan_commission_enabled BOOLEAN NOT NULL DEFAULT true,
+ loan_commission_pct NUMERIC(5,2) NOT NULL DEFAULT 10,
+ loan_small_max NUMERIC(12,2) NOT NULL DEFAULT 500
+);
+
+--changeset panel:core-001t-payroll-weeks endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='payroll_weeks'
+CREATE TABLE core.payroll_weeks (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ week_start DATE NOT NULL,
+ week_end DATE NOT NULL,
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'assembled', 'paid')),
+ required_net NUMERIC(14,2) NOT NULL DEFAULT 0,
+ payable_net NUMERIC(14,2) NOT NULL DEFAULT 0,
+ assembled_at TIMESTAMPTZ,
+ paid_at TIMESTAMPTZ,
+ tenant_id INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (tenant_id, week_start)
+);
+CREATE INDEX idx_core_payroll_weeks_tenant ON core.payroll_weeks(tenant_id);
+
+--changeset panel:core-001u-payroll-sheets endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='payroll_sheets'
+CREATE TABLE core.payroll_sheets (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ week_id INTEGER NOT NULL REFERENCES core.payroll_weeks(id) ON DELETE CASCADE,
+ kind TEXT NOT NULL CHECK (kind IN ('obra', 'destajo', 'admin')),
+ project_id INTEGER REFERENCES core.projects(id)
+);
+CREATE UNIQUE INDEX idx_core_payroll_sheets_week_kind_project
+ ON core.payroll_sheets(week_id, kind, COALESCE(project_id, 0));
+
+--changeset panel:core-001v-payroll-week-lines endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='payroll_week_lines'
+CREATE TABLE core.payroll_week_lines (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ sheet_id INTEGER NOT NULL REFERENCES core.payroll_sheets(id) ON DELETE CASCADE,
+ worker_id INTEGER NOT NULL REFERENCES core.workers(id),
+ destajo_cut_line_id INTEGER,
+ project_id INTEGER REFERENCES core.projects(id),
+ days NUMERIC(5,2) NOT NULL DEFAULT 0,
+ daily_wage NUMERIC(12,2) NOT NULL DEFAULT 0,
+ qty_planned NUMERIC(14,4) NOT NULL DEFAULT 0,
+ qty_actual NUMERIC(14,4) NOT NULL DEFAULT 0,
+ qty_extra NUMERIC(14,4) NOT NULL DEFAULT 0,
+ unit_price NUMERIC(14,4) NOT NULL DEFAULT 0,
+ unit_code TEXT,
+ concepto TEXT,
+ amount NUMERIC(14,2) NOT NULL DEFAULT 0,
+ gross NUMERIC(14,2) NOT NULL DEFAULT 0,
+ discounts NUMERIC(14,2) NOT NULL DEFAULT 0,
+ loan_id INTEGER REFERENCES core.loans(id),
+ loan_discount NUMERIC(14,2) NOT NULL DEFAULT 0,
+ loan_label TEXT,
+ required_net NUMERIC(14,2) NOT NULL DEFAULT 0,
+ payable_net NUMERIC(14,2) NOT NULL DEFAULT 0
+);
+
+--changeset panel:core-001w-destajo-units endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='destajo_units'
+CREATE TABLE core.destajo_units (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ tenant_id INTEGER NOT NULL DEFAULT 0,
+ code TEXT NOT NULL,
+ label TEXT NOT NULL,
+ UNIQUE (tenant_id, code)
+);
+CREATE INDEX idx_core_destajo_units_tenant ON core.destajo_units(tenant_id);
+
+--changeset panel:core-001x-destajo-periods endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='destajo_periods'
+CREATE TABLE core.destajo_periods (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ period_start DATE NOT NULL,
+ period_end DATE NOT NULL,
+ week_id INTEGER NOT NULL REFERENCES core.payroll_weeks(id) ON DELETE CASCADE,
+ tenant_id INTEGER NOT NULL DEFAULT 0,
+ UNIQUE (tenant_id, period_end)
+);
+CREATE INDEX idx_core_destajo_periods_tenant ON core.destajo_periods(tenant_id);
+
+--changeset panel:core-001y-destajo-jobs endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='destajo_jobs'
+CREATE TABLE core.destajo_jobs (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ project_id INTEGER NOT NULL REFERENCES core.projects(id),
+ worker_id INTEGER NOT NULL REFERENCES core.workers(id),
+ concepto TEXT NOT NULL,
+ unit_code TEXT NOT NULL,
+ qty_total_estimated NUMERIC(14,4) NOT NULL,
+ unit_price NUMERIC(14,4) NOT NULL,
+ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'done')),
+ tenant_id INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX idx_core_destajo_jobs_tenant ON core.destajo_jobs(tenant_id);
+
+--changeset panel:core-001z-destajo-cut-lines endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='destajo_cut_lines'
+CREATE TABLE core.destajo_cut_lines (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ period_id INTEGER NOT NULL REFERENCES core.destajo_periods(id) ON DELETE CASCADE,
+ job_id INTEGER NOT NULL REFERENCES core.destajo_jobs(id) ON DELETE CASCADE,
+ qty_planned NUMERIC(14,4) NOT NULL DEFAULT 0,
+ qty_actual NUMERIC(14,4) NOT NULL DEFAULT 0,
+ qty_extra NUMERIC(14,4) NOT NULL DEFAULT 0,
+ UNIQUE (period_id, job_id)
+);
+
+--changeset panel:core-001aa-loan-payments endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='loan_payments'
+CREATE TABLE core.loan_payments (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ loan_id INTEGER NOT NULL REFERENCES core.loans(id) ON DELETE CASCADE,
+ week_id INTEGER NOT NULL REFERENCES core.payroll_weeks(id),
+ amount NUMERIC(12,2) NOT NULL,
+ installment_n INTEGER NOT NULL DEFAULT 1,
+ label TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+--changeset panel:core-001ab-budget-chapters endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='budget_chapters'
+CREATE TABLE core.budget_chapters (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ project_id INTEGER NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
+ parent_id INTEGER REFERENCES core.budget_chapters(id) ON DELETE SET NULL,
+ code TEXT NOT NULL DEFAULT '',
+ name TEXT NOT NULL,
+ wbs TEXT NOT NULL DEFAULT '',
+ sort_order INTEGER NOT NULL DEFAULT 0
+);
+
+--changeset panel:core-001ac-budget-items endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='budget_items'
+CREATE TABLE core.budget_items (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ project_id INTEGER NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
+ chapter_id INTEGER REFERENCES core.budget_chapters(id) ON DELETE SET NULL,
+ code TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL,
+ unit TEXT NOT NULL DEFAULT '',
+ quantity NUMERIC(14,4) NOT NULL DEFAULT 0,
+ unit_price NUMERIC(14,4) NOT NULL DEFAULT 0,
+ amount NUMERIC(14,2) NOT NULL DEFAULT 0,
+ wbs TEXT NOT NULL DEFAULT '',
+ sort_order INTEGER NOT NULL DEFAULT 0
+);
diff --git a/db/core/changesets/002-tenant-settings.sql b/db/core/changesets/002-tenant-settings.sql
new file mode 100644
index 0000000..a590e68
--- /dev/null
+++ b/db/core/changesets/002-tenant-settings.sql
@@ -0,0 +1,17 @@
+--liquibase formatted sql
+-- PANELS · panels_product.core (Postgres) · Fase 4d
+--
+-- Zona horaria configurable por tenant. Reemplaza el PAYROLL_TZ hardcodeado
+-- de api/payroll.ts ("America/Cancun"). Se separa de core.payroll_settings
+-- a propósito: la zona horaria la usan también document_validity.ts y
+-- cualquier validación de fecha del sistema, no solo nómina.
+
+--changeset panel:core-002a-tenant-settings endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='tenant_settings'
+CREATE TABLE core.tenant_settings (
+ tenant_id INTEGER PRIMARY KEY,
+ timezone TEXT NOT NULL DEFAULT 'America/Mexico_City',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
diff --git a/db/core/changesets/003-seed-catalogs.sql b/db/core/changesets/003-seed-catalogs.sql
new file mode 100644
index 0000000..cad74f5
--- /dev/null
+++ b/db/core/changesets/003-seed-catalogs.sql
@@ -0,0 +1,73 @@
+--liquibase formatted sql
+-- PANELS · panels_product.core (Postgres) · catálogos requeridos
+--
+-- A diferencia de los datos de demo (changeset siguiente, context:dev),
+-- esto NO es data de ejemplo: la app asume que estos catálogos existen en
+-- cualquier ambiente (referencian risk_code/theme_id/type_code desde
+-- workers/projects/documents). Por eso corre sin context, en todos lados.
+
+--changeset panel:core-003a-risk-levels endDelimiter:; splitStatements:true
+INSERT INTO core.risk_levels (code, label, color, text_color) VALUES
+ ('rojo', 'Rojo', '#A20000', '#FFFFFF'),
+ ('amarillo', 'Amarillo', '#EEEE3C', '#000000'),
+ ('azul', 'Azul', '#001485', '#FFFFFF'),
+ ('verde', 'Verde', '#008514', '#FFFFFF'),
+ ('negro', 'Negro', '#000000', '#FFFFFF'),
+ ('naranja', 'Naranja', '#FF5733', '#FFFFFF')
+ON CONFLICT (code) DO NOTHING;
+
+--changeset panel:core-003b-badge-themes endDelimiter:; splitStatements:true
+INSERT INTO core.badge_themes (id, name, layout) VALUES
+ ('arctec-dos-logos-fold', 'Arctec dos logos (doblez carta)', 'letter-landscape-4-fold')
+ON CONFLICT (id) DO NOTHING;
+
+--changeset panel:core-003c-document-types endDelimiter:; splitStatements:true
+INSERT INTO core.document_types (code, label, required, category, validity_mode, freshness_days, requires_issued_at, requires_expires_at) VALUES
+ ('foto', 'Foto', true, 'identidad', 'none', NULL, false, false),
+ ('ine', 'INE', true, 'identidad', 'expiry', NULL, false, true),
+ ('curp', 'Constancia CURP', true, 'identidad', 'freshness', 90, true, false),
+ ('nss', 'Constancia NSS', true, 'identidad', 'freshness', 90, true, false),
+ ('rfc', 'Constancia RFC', true, 'identidad', 'freshness', 90, true, false),
+ ('domicilio', 'Comprobante de domicilio', true, 'identidad', 'freshness', 90, true, false),
+ ('alta_imss', 'Alta IMSS', false, 'imss', 'none', NULL, false, false),
+ ('baja_imss', 'Baja IMSS', false, 'imss', 'none', NULL, false, false),
+ ('contrato', 'Contrato laboral', false, 'laboral', 'none', NULL, false, false),
+ ('aviso_privacidad', 'Aviso de privacidad', false, 'laboral', 'none', NULL, false, false),
+ ('dc3_nom031', 'DC-3 NOM-031 (construcción)', false, 'sst', 'expiry', NULL, false, false),
+ ('epp', 'Acuse de entrega de EPP', false, 'sst', 'none', NULL, false, false),
+ ('medico', 'Certificado médico', false, 'sst', 'expiry', NULL, false, false),
+ ('dc3_altura', 'DC-3 trabajo en alturas', false, 'oficio', 'expiry', NULL, false, false),
+ ('dc3_soldadura', 'DC-3 soldadura', false, 'oficio', 'expiry', NULL, false, false),
+ ('dc3_montacargas', 'DC-3 montacargas', false, 'oficio', 'expiry', NULL, false, false),
+ ('licencia', 'Licencia de conducir', false, 'oficio', 'expiry', NULL, false, false),
+ ('cedula', 'Cédula profesional', false, 'oficio', 'none', NULL, false, false),
+ ('otro', 'Otro', false, 'otro', 'none', NULL, false, false)
+ON CONFLICT (code) DO NOTHING;
+
+--changeset panel:core-003d-project-document-types endDelimiter:; splitStatements:true
+INSERT INTO core.project_document_types (code, label, required, category) VALUES
+ ('contrato', 'Contrato', true, 'contrato'),
+ ('presupuesto', 'Presupuesto', true, 'contrato'),
+ ('planos', 'Planos', false, 'tecnico'),
+ ('licencia_construccion', 'Licencia de construcción', false, 'permisos'),
+ ('uso_suelo', 'Uso de suelo', false, 'permisos'),
+ ('proteccion_civil', 'Anuencia de Protección Civil', false, 'permisos'),
+ ('mia', 'MIA / resolutivo ambiental', false, 'ambiental'),
+ ('siroc_acuse', 'Acuse SIROC', false, 'imss'),
+ ('programa_sst', 'Programa de seguridad', false, 'sst'),
+ ('seguro_obra', 'Seguro de obra', false, 'contrato'),
+ ('fianza', 'Fianza', false, 'contrato'),
+ ('otro', 'Otro', false, 'otro')
+ON CONFLICT (code) DO NOTHING;
+
+--changeset panel:core-003e-company-document-types endDelimiter:; splitStatements:true
+INSERT INTO core.company_document_types (code, label, required, category) VALUES
+ ('repse_federal', 'REPSE federal (STPS)', false, 'repse'),
+ ('repse_estatal', 'REPSE estatal (SATQ)', false, 'repse'),
+ ('opinion_sat', 'Opinión de cumplimiento SAT (32-D)', false, 'fiscal'),
+ ('opinion_imss', 'Opinión de cumplimiento IMSS', false, 'fiscal'),
+ ('opinion_infonavit', 'Opinión de cumplimiento INFONAVIT', false, 'fiscal'),
+ ('acta_constitutiva', 'Acta constitutiva', false, 'legal'),
+ ('poder_notarial', 'Poder notarial', false, 'legal'),
+ ('otro', 'Otro', false, 'otro')
+ON CONFLICT (code) DO NOTHING;
diff --git a/db/core/changesets/004-seed-dev-data.sql b/db/core/changesets/004-seed-dev-data.sql
new file mode 100644
index 0000000..4a5025e
--- /dev/null
+++ b/db/core/changesets/004-seed-dev-data.sql
@@ -0,0 +1,31 @@
+--liquibase formatted sql
+-- PANELS · panels_product.core (Postgres) · datos de demostración
+--
+-- Solo para dev/pruebas -- nunca staging/producción. tenant_id=1 asume el
+-- mismo id fijo que db/platform/changesets/001-baseline.sql (changeset
+-- platform-001d) le da a ARCT2608; no hay FK real entre bases que lo
+-- garantice, así que si cambia uno hay que cambiar el otro.
+
+--changeset panel:core-004a-seed-companies context:dev endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM core.companies WHERE code = 'ARCT2608'
+INSERT INTO core.companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social)
+VALUES ('ARCT2608', 'ARCTEC', NULL, 'principal', 1, 'ARCTEC', 'ARCTEC');
+
+INSERT INTO core.companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social)
+SELECT 'FISICA', 'FISICA', id, 'sub', 1, 'FISICA', 'FISICA' FROM core.companies WHERE code = 'ARCT2608';
+
+INSERT INTO core.companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social)
+SELECT 'ARCOTEC', 'ARCOTEC', id, 'sub', 1, 'ARCOTEC', 'ARCOTEC' FROM core.companies WHERE code = 'ARCT2608';
+
+--changeset panel:core-004b-seed-project context:dev endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM core.projects WHERE code = 'PRY-0001'
+INSERT INTO core.projects (code, name, address, theme_id, company_id, tenant_id)
+SELECT 'PRY-0001', 'ZENDALA CANCUN', '', 'arctec-dos-logos-fold', id, 1
+FROM core.companies WHERE code = 'ARCT2608';
+
+--changeset panel:core-004c-seed-tenant-settings context:dev endDelimiter:;
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM core.tenant_settings WHERE tenant_id = 1
+INSERT INTO core.tenant_settings (tenant_id, timezone) VALUES (1, 'America/Cancun');
diff --git a/db/core/changesets/005-rls.sql b/db/core/changesets/005-rls.sql
new file mode 100644
index 0000000..66b52c4
--- /dev/null
+++ b/db/core/changesets/005-rls.sql
@@ -0,0 +1,71 @@
+--liquibase formatted sql
+-- PANELS · panels_product.core (Postgres) · Row Level Security (Fase 4b)
+--
+-- Mismo mecanismo que iam (ver db/iam/changesets/002-rls.sql): el rol de
+-- runtime (panels_core_app) no es dueño de las tablas, así que
+-- ENABLE ROW LEVEL SECURITY ya lo restringe sin afectar a panels_core_owner
+-- (que sí necesita ver todos los tenants para migrar/hacer backfills).
+--
+-- Alcance: se aplica a las tablas con columna tenant_id directa --
+-- companies, workers, projects, payroll_weeks, destajo_units/periods/jobs.
+-- Tablas hijas sin tenant_id propio (assignments, documents, budget_items,
+-- loans, attendance, badge_jobs, payroll_week_lines, etc.) dependen del
+-- scoping corregido en la capa de aplicación (Fase 3, cierre del IDOR) via
+-- su FK a projects/workers -- extender RLS a esas tablas con políticas por
+-- subquery es un endurecimiento futuro válido, no bloqueante para esta fase.
+
+--changeset panel:core-005a-rls-companies endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='companies' AND policyname='tenant_isolation'
+ALTER TABLE core.companies ENABLE ROW LEVEL SECURITY;
+CREATE POLICY tenant_isolation ON core.companies
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
+
+--changeset panel:core-005b-rls-workers endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='workers' AND policyname='tenant_isolation'
+ALTER TABLE core.workers ENABLE ROW LEVEL SECURITY;
+CREATE POLICY tenant_isolation ON core.workers
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
+
+--changeset panel:core-005c-rls-projects endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='projects' AND policyname='tenant_isolation'
+ALTER TABLE core.projects ENABLE ROW LEVEL SECURITY;
+CREATE POLICY tenant_isolation ON core.projects
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
+
+--changeset panel:core-005d-rls-payroll-weeks endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='payroll_weeks' AND policyname='tenant_isolation'
+ALTER TABLE core.payroll_weeks ENABLE ROW LEVEL SECURITY;
+CREATE POLICY tenant_isolation ON core.payroll_weeks
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
+
+--changeset panel:core-005e-rls-destajo-units endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='destajo_units' AND policyname='tenant_isolation'
+ALTER TABLE core.destajo_units ENABLE ROW LEVEL SECURITY;
+CREATE POLICY tenant_isolation ON core.destajo_units
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
+
+--changeset panel:core-005f-rls-destajo-periods endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='destajo_periods' AND policyname='tenant_isolation'
+ALTER TABLE core.destajo_periods ENABLE ROW LEVEL SECURITY;
+CREATE POLICY tenant_isolation ON core.destajo_periods
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
+
+--changeset panel:core-005g-rls-destajo-jobs endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='destajo_jobs' AND policyname='tenant_isolation'
+ALTER TABLE core.destajo_jobs ENABLE ROW LEVEL SECURITY;
+CREATE POLICY tenant_isolation ON core.destajo_jobs
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
diff --git a/db/core/liquibase.properties b/db/core/liquibase.properties
new file mode 100644
index 0000000..1a3919f
--- /dev/null
+++ b/db/core/liquibase.properties
@@ -0,0 +1,9 @@
+# PANELS · Liquibase · panels_product, esquema core (Postgres)
+#
+# url/username/password se pasan por variable de entorno o -D en la línea
+# de comandos (ver db/update.sh); no se commitean credenciales aquí.
+changelogFile=changelog-master.xml
+liquibaseSchemaName=core
+defaultSchemaName=core
+driver=org.postgresql.Driver
+classpath=../tools/postgresql-jdbc.jar
diff --git a/db/iam/changelog-master.xml b/db/iam/changelog-master.xml
new file mode 100644
index 0000000..15ba648
--- /dev/null
+++ b/db/iam/changelog-master.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/db/iam/changesets/001-baseline.sql b/db/iam/changesets/001-baseline.sql
new file mode 100644
index 0000000..0160f08
--- /dev/null
+++ b/db/iam/changesets/001-baseline.sql
@@ -0,0 +1,92 @@
+--liquibase formatted sql
+-- PANELS · panels_product.iam (Postgres) · baseline
+--
+-- Identidad y RBAC DE CADA TENANT (ej. empleados de ARCTEC que usan
+-- web-panel). No confundir con panels_platform.platform_users (identidad
+-- de PANELS como operador SaaS) -- son dos sistemas de identidad distintos
+-- que no comparten tabla ni base de datos.
+--
+-- Este esquema NO tiene FK hacia `core` (ver reglas del monolito modular
+-- en el plan): documents.uploaded_by/badge_jobs.created_by pasan a
+-- snapshot desnormalizado en `core`, y users.company_id es una referencia
+-- lógica a core.companies(id) sin FK real.
+
+--changeset panel:iam-001a-roles endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='iam' AND table_name='roles'
+CREATE TABLE iam.roles (
+ code TEXT PRIMARY KEY,
+ label TEXT NOT NULL
+);
+
+INSERT INTO iam.roles (code, label) VALUES
+ ('tenant_admin', 'Administrador del tenant'),
+ ('user', 'Usuario operativo');
+
+--changeset panel:iam-001b-permissions endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='iam' AND table_name='permissions'
+--comment: Catálogo base de permisos. El código de la app hoy sigue gateando
+-- por role_code (igual que el string "role" anterior); esta tabla es la
+-- base para pasar a chequeos granulares por permiso de forma incremental,
+-- sin bloquear la migración a Postgres en ese rediseño más grande.
+CREATE TABLE iam.permissions (
+ code TEXT PRIMARY KEY,
+ label TEXT NOT NULL
+);
+
+INSERT INTO iam.permissions (code, label) VALUES
+ ('manage_workers', 'Alta, edición y expediente de personal'),
+ ('manage_projects', 'Alta y edición de obras'),
+ ('manage_companies', 'Alta y edición de empresas'),
+ ('manage_budget', 'Presupuesto de obra'),
+ ('manage_payroll', 'Nómina y préstamos'),
+ ('manage_documents', 'Documentos de personal/obra/empresa'),
+ ('manage_users', 'Alta y edición de usuarios del tenant'),
+ ('manage_settings', 'Configuración del tenant (zona horaria, etc.)'),
+ ('view_reports', 'Reportes y tableros');
+
+--changeset panel:iam-001c-role-permissions endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='iam' AND table_name='role_permissions'
+CREATE TABLE iam.role_permissions (
+ role_code TEXT NOT NULL REFERENCES iam.roles(code) ON DELETE CASCADE,
+ permission_code TEXT NOT NULL REFERENCES iam.permissions(code) ON DELETE CASCADE,
+ PRIMARY KEY (role_code, permission_code)
+);
+
+INSERT INTO iam.role_permissions (role_code, permission_code)
+SELECT 'tenant_admin', code FROM iam.permissions;
+
+INSERT INTO iam.role_permissions (role_code, permission_code) VALUES
+ ('user', 'manage_workers'),
+ ('user', 'manage_documents'),
+ ('user', 'view_reports');
+
+--changeset panel:iam-001d-users endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='iam' AND table_name='users'
+CREATE TABLE iam.users (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ display_name TEXT NOT NULL,
+ -- Referencia lógica a core.companies(id) -- SIN FK real (iam y core están
+ -- aislados a propósito; ver reglas del monolito modular).
+ company_id INTEGER,
+ -- Referencia lógica a panels_platform.tenants(id) -- SIN FK real (bases
+ -- de datos distintas, Postgres no permite FK entre databases).
+ tenant_id INTEGER,
+ role_code TEXT NOT NULL DEFAULT 'user' REFERENCES iam.roles(code),
+ must_change_password BOOLEAN NOT NULL DEFAULT false,
+ email TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_iam_users_tenant ON iam.users(tenant_id);
+
+-- Nota: el primer usuario (platform_admin y el tenant_admin de cada tenant
+-- nuevo) NO se siembra aquí. Un password_hash real requiere PBKDF2 (mismo
+-- algoritmo que api/crypto.ts), que Liquibase/SQL no puede calcular. Ese
+-- bootstrap es un comando explícito y one-shot: scripts/bootstrap-admin.ts
+-- (Fase 4), no lógica de seed en el arranque de la app ni en un changeset.
diff --git a/db/iam/changesets/002-rls.sql b/db/iam/changesets/002-rls.sql
new file mode 100644
index 0000000..4403dfb
--- /dev/null
+++ b/db/iam/changesets/002-rls.sql
@@ -0,0 +1,28 @@
+--liquibase formatted sql
+-- PANELS · panels_product.iam (Postgres) · Row Level Security (Fase 4b)
+--
+-- Defensa en profundidad contra el bug de IDOR cross-tenant encontrado en
+-- la revisión de seguridad: aunque un handler olvide el WHERE tenant_id=?,
+-- Postgres ya no devuelve/permite escribir filas de otro tenant.
+--
+-- Cómo se activa: el rol de runtime (panels_iam_app) NO es dueño de la
+-- tabla (panels_iam_owner sí lo es), así que ENABLE ROW LEVEL SECURITY ya
+-- restringe automáticamente a panels_iam_app sin necesitar FORCE (FORCE
+-- haría que la política también aplique al dueño, lo cual rompería las
+-- migraciones/backfills que sí necesitan ver todos los tenants).
+--
+-- El valor de comparación viene de un parámetro de sesión que la API fija
+-- al inicio de cada transacción: SET LOCAL app.tenant_id = ''. Si el
+-- código olvida fijarlo, current_setting(...) devuelve NULL y la
+-- comparación "tenant_id = NULL" es siempre falsa -- es decir, "fail
+-- closed": sin tenant_id explícito no se ve ni se escribe ninguna fila,
+-- en vez de exponer todo por defecto.
+
+--changeset panel:iam-002a-rls-users endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='iam' AND tablename='users' AND policyname='tenant_isolation'
+ALTER TABLE iam.users ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY tenant_isolation ON iam.users
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
diff --git a/db/iam/liquibase.properties b/db/iam/liquibase.properties
new file mode 100644
index 0000000..a51e4ec
--- /dev/null
+++ b/db/iam/liquibase.properties
@@ -0,0 +1,11 @@
+# PANELS · Liquibase · panels_product, esquema iam (Postgres)
+#
+# url/username/password se pasan por variable de entorno o -D en la línea
+# de comandos (ver db/update.sh); no se commitean credenciales aquí.
+# liquibaseSchemaName controla dónde vive el DATABASECHANGELOG de este
+# módulo -- separado del de `core`, aunque compartan la misma base de datos.
+changelogFile=changelog-master.xml
+liquibaseSchemaName=iam
+defaultSchemaName=iam
+driver=org.postgresql.Driver
+classpath=../tools/postgresql-jdbc.jar
diff --git a/db/platform/changelog-master.xml b/db/platform/changelog-master.xml
index 050c58d..68f0ec2 100644
--- a/db/platform/changelog-master.xml
+++ b/db/platform/changelog-master.xml
@@ -1,15 +1,11 @@
+ xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+ http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
+
+
+
-
-
-
-
-
-
-
diff --git a/db/platform/changesets/001-baseline.sql b/db/platform/changesets/001-baseline.sql
new file mode 100644
index 0000000..5937993
--- /dev/null
+++ b/db/platform/changesets/001-baseline.sql
@@ -0,0 +1,66 @@
+--liquibase formatted sql
+-- PANELS · panels_platform (Postgres) · baseline
+--
+-- Control plane SaaS: tenants, operadores de PANELS (platform_users) y
+-- configuración SMTP. Base de datos físicamente separada de panels_product
+-- (ver plan de migración) -- nada aquí se referencia desde iam/core con FK.
+--
+-- Este baseline representa el ESTADO FINAL equivalente a los changesets
+-- SQLite 001..007 de la implementación anterior, no un replay literal
+-- (varios de esos changesets eran parches específicos de SQLite, p. ej.
+-- el rebuild de tabla en 004-tenant-status-expand.sql para ampliar un CHECK).
+
+--changeset panel:platform-001a-tenants endDelimiter:;
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='tenants'
+CREATE TABLE tenants (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ code TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'suspendido', 'cancelado')),
+ plan TEXT NOT NULL DEFAULT 'trial',
+ valid_until DATE,
+ notes TEXT NOT NULL DEFAULT '',
+ contact_email TEXT NOT NULL DEFAULT '',
+ contact_name TEXT NOT NULL DEFAULT '',
+ access_sent_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+--changeset panel:platform-001b-platform-users endDelimiter:;
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='platform_users'
+CREATE TABLE platform_users (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ display_name TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+--changeset panel:platform-001c-smtp-settings endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='smtp_settings'
+CREATE TABLE smtp_settings (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ host TEXT NOT NULL DEFAULT '',
+ port INTEGER NOT NULL DEFAULT 587,
+ username TEXT NOT NULL DEFAULT '',
+ password TEXT NOT NULL DEFAULT '',
+ from_address TEXT NOT NULL DEFAULT '',
+ enabled BOOLEAN NOT NULL DEFAULT false,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+INSERT INTO smtp_settings (id, host, port, username, password, from_address, enabled)
+VALUES (1, '', 587, '', '', '', false);
+
+--changeset panel:platform-001d-seed-arctec-tenant context:dev endDelimiter:; splitStatements:true
+--comment: Datos de demostración -- nunca en staging/producción. id=1 fijo a
+-- propósito: los changesets de dev-seed de core/iam asumen este mismo id
+-- para tenant_id (no hay FK real entre bases, así que se sincroniza a mano).
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM tenants WHERE code = 'ARCT2608'
+INSERT INTO tenants (id, code, name, status) OVERRIDING SYSTEM VALUE VALUES (1, 'ARCT2608', 'ARCTEC', 'activo');
+SELECT setval(pg_get_serial_sequence('tenants', 'id'), (SELECT MAX(id) FROM tenants));
diff --git a/db/platform/changesets/001-init.sql b/db/platform/changesets/001-init.sql
deleted file mode 100644
index a4621db..0000000
--- a/db/platform/changesets/001-init.sql
+++ /dev/null
@@ -1,20 +0,0 @@
---liquibase formatted sql
-
---changeset panel:platform-001-init endDelimiter:; splitStatements:true
-
-CREATE TABLE IF NOT EXISTS tenants (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- code TEXT NOT NULL UNIQUE,
- name TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
-CREATE TABLE IF NOT EXISTS platform_users (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- username TEXT NOT NULL UNIQUE,
- password_hash TEXT NOT NULL,
- display_name TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
diff --git a/db/platform/changesets/002-seed-arctec-tenant.sql b/db/platform/changesets/002-seed-arctec-tenant.sql
deleted file mode 100644
index 1b661fd..0000000
--- a/db/platform/changesets/002-seed-arctec-tenant.sql
+++ /dev/null
@@ -1,7 +0,0 @@
---liquibase formatted sql
-
---changeset panel:platform-002-seed-arctec endDelimiter:; splitStatements:true
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM tenants WHERE code = 'ARCTEC'
-
-INSERT INTO tenants (id, code, name, status) VALUES (1, 'ARCTEC', 'ARCTEC', 'activo');
diff --git a/db/platform/changesets/003-tenant-license.sql b/db/platform/changesets/003-tenant-license.sql
deleted file mode 100644
index 56c093d..0000000
--- a/db/platform/changesets/003-tenant-license.sql
+++ /dev/null
@@ -1,16 +0,0 @@
---liquibase formatted sql
-
---changeset panel:platform-003a-plan endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='plan'
-ALTER TABLE tenants ADD COLUMN plan TEXT NOT NULL DEFAULT 'trial';
-
---changeset panel:platform-003b-valid-until endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='valid_until'
-ALTER TABLE tenants ADD COLUMN valid_until TEXT;
-
---changeset panel:platform-003c-notes endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='notes'
-ALTER TABLE tenants ADD COLUMN notes TEXT NOT NULL DEFAULT '';
diff --git a/db/platform/changesets/004-tenant-status-expand.sql b/db/platform/changesets/004-tenant-status-expand.sql
deleted file mode 100644
index d37e667..0000000
--- a/db/platform/changesets/004-tenant-status-expand.sql
+++ /dev/null
@@ -1,30 +0,0 @@
---liquibase formatted sql
-
---changeset panel:platform-004-status-expand endDelimiter:; splitStatements:true
---comment: Expand tenant status to activo|suspendido|cancelado (SQLite rebuild)
-
-CREATE TABLE tenants_new (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- code TEXT NOT NULL UNIQUE,
- name TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'suspendido', 'cancelado')),
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
- plan TEXT NOT NULL DEFAULT 'trial',
- valid_until TEXT,
- notes TEXT NOT NULL DEFAULT ''
-);
-
-INSERT INTO tenants_new (id, code, name, status, created_at, plan, valid_until, notes)
-SELECT
- id,
- code,
- name,
- CASE WHEN status = 'inactivo' THEN 'suspendido' ELSE status END,
- created_at,
- COALESCE(plan, 'trial'),
- valid_until,
- COALESCE(notes, '')
-FROM tenants;
-
-DROP TABLE tenants;
-ALTER TABLE tenants_new RENAME TO tenants;
diff --git a/db/platform/changesets/005-contact-access.sql b/db/platform/changesets/005-contact-access.sql
deleted file mode 100644
index fdd9f14..0000000
--- a/db/platform/changesets/005-contact-access.sql
+++ /dev/null
@@ -1,16 +0,0 @@
---liquibase formatted sql
-
---changeset panel:platform-005a-contact-email endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='contact_email'
-ALTER TABLE tenants ADD COLUMN contact_email TEXT NOT NULL DEFAULT '';
-
---changeset panel:platform-005b-contact-name endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='contact_name'
-ALTER TABLE tenants ADD COLUMN contact_name TEXT NOT NULL DEFAULT '';
-
---changeset panel:platform-005c-access-sent-at endDelimiter:;
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='access_sent_at'
-ALTER TABLE tenants ADD COLUMN access_sent_at TEXT;
diff --git a/db/platform/changesets/006-tenant-code-date.sql b/db/platform/changesets/006-tenant-code-date.sql
deleted file mode 100644
index 3d97866..0000000
--- a/db/platform/changesets/006-tenant-code-date.sql
+++ /dev/null
@@ -1,7 +0,0 @@
---liquibase formatted sql
-
---changeset panel:platform-006-tenant-code-date endDelimiter:; splitStatements:true
---preconditions onFail:MARK_RAN
---precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM tenants WHERE code = 'ARCTEC'
-
-UPDATE tenants SET code = 'ARCT2608' WHERE code = 'ARCTEC';
diff --git a/db/platform/changesets/007-smtp-settings.sql b/db/platform/changesets/007-smtp-settings.sql
deleted file mode 100644
index 9d88021..0000000
--- a/db/platform/changesets/007-smtp-settings.sql
+++ /dev/null
@@ -1,18 +0,0 @@
---liquibase formatted sql
-
---changeset panel:platform-007-smtp-settings endDelimiter:; splitStatements:true
-
-CREATE TABLE IF NOT EXISTS smtp_settings (
- id INTEGER PRIMARY KEY CHECK (id = 1),
- host TEXT NOT NULL DEFAULT '',
- port INTEGER NOT NULL DEFAULT 587,
- username TEXT NOT NULL DEFAULT '',
- password TEXT NOT NULL DEFAULT '',
- from_address TEXT NOT NULL DEFAULT '',
- enabled INTEGER NOT NULL DEFAULT 0,
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
-);
-
-INSERT INTO smtp_settings (id, host, port, username, password, from_address, enabled)
-SELECT 1, '', 587, '', '', '', 0
-WHERE NOT EXISTS (SELECT 1 FROM smtp_settings WHERE id = 1);
diff --git a/db/platform/liquibase.properties b/db/platform/liquibase.properties
index 908e48b..f355236 100644
--- a/db/platform/liquibase.properties
+++ b/db/platform/liquibase.properties
@@ -1,4 +1,8 @@
-changeLogFile=changelog-master.xml
-driver=org.sqlite.JDBC
-url=jdbc:sqlite:../../data/platform.db
-classpath=../tools/sqlite-jdbc.jar
+# PANELS · Liquibase · panels_platform (Postgres)
+#
+# url/username/password se pasan por variable de entorno o -D en la línea
+# de comandos (ver db/update.sh); no se commitean credenciales aquí.
+changelogFile=changelog-master.xml
+liquibaseSchemaName=public
+driver=org.postgresql.Driver
+classpath=../tools/postgresql-jdbc.jar
diff --git a/db/provision/01-roles.sql b/db/provision/01-roles.sql
new file mode 100644
index 0000000..c5fe0e1
--- /dev/null
+++ b/db/provision/01-roles.sql
@@ -0,0 +1,51 @@
+-- PANELS · Postgres · Fase 0 (aprovisionamiento de roles)
+--
+-- Crea los 6 roles cluster-wide usados por PANELS: dos por módulo
+-- (platform, iam, core), un rol "_owner" para Liquibase/migraciones y un
+-- rol "_app" para runtime con privilegios acotados a su esquema/base.
+--
+-- Idempotente vía el idiom \gexec (solo emite el CREATE ROLE si todavía
+-- no existe). Nota: la interpolación de variables de psql (:'var') NO
+-- funciona dentro de bloques DO $$...$$ (dollar-quoting), por eso este
+-- script arma el DDL con format(...)+\gexec en vez de un DO block.
+--
+-- Uso (contra un Postgres nuevo, como superuser/admin de Coolify). Los
+-- valores se pasan SIN comillas propias; :'var' ya produce un literal SQL
+-- correctamente escapado:
+-- psql "$SUPERUSER_URL" \
+-- -v platform_owner_pw="CAMBIA-ESTA-CLAVE-1" \
+-- -v platform_app_pw="CAMBIA-ESTA-CLAVE-2" \
+-- -v iam_owner_pw="CAMBIA-ESTA-CLAVE-3" \
+-- -v iam_app_pw="CAMBIA-ESTA-CLAVE-4" \
+-- -v core_owner_pw="CAMBIA-ESTA-CLAVE-5" \
+-- -v core_app_pw="CAMBIA-ESTA-CLAVE-6" \
+-- -f 01-roles.sql
+--
+-- Genera claves fuertes por ambiente con: openssl rand -hex 24
+
+SELECT format('CREATE ROLE panels_platform_owner LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE', :'platform_owner_pw')
+WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'panels_platform_owner')\gexec
+
+SELECT format('CREATE ROLE panels_platform_app LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE', :'platform_app_pw')
+WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'panels_platform_app')\gexec
+
+SELECT format('CREATE ROLE panels_iam_owner LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE', :'iam_owner_pw')
+WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'panels_iam_owner')\gexec
+
+SELECT format('CREATE ROLE panels_iam_app LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE', :'iam_app_pw')
+WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'panels_iam_app')\gexec
+
+SELECT format('CREATE ROLE panels_core_owner LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE', :'core_owner_pw')
+WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'panels_core_owner')\gexec
+
+SELECT format('CREATE ROLE panels_core_app LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE', :'core_app_pw')
+WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'panels_core_app')\gexec
+
+-- Si el rol ya existía de una corrida anterior, refresca su password al
+-- valor actual (para poder rotar credenciales corriendo este script de nuevo).
+ALTER ROLE panels_platform_owner PASSWORD :'platform_owner_pw';
+ALTER ROLE panels_platform_app PASSWORD :'platform_app_pw';
+ALTER ROLE panels_iam_owner PASSWORD :'iam_owner_pw';
+ALTER ROLE panels_iam_app PASSWORD :'iam_app_pw';
+ALTER ROLE panels_core_owner PASSWORD :'core_owner_pw';
+ALTER ROLE panels_core_app PASSWORD :'core_app_pw';
diff --git a/db/provision/02-databases.sql b/db/provision/02-databases.sql
new file mode 100644
index 0000000..c7c584d
--- /dev/null
+++ b/db/provision/02-databases.sql
@@ -0,0 +1,33 @@
+-- PANELS · Postgres · Fase 0 (aprovisionamiento de bases)
+--
+-- Crea las dos bases de datos físicamente separadas del monolito modular:
+-- panels_platform -> control plane SaaS (tenants, platform_users, smtp_settings)
+-- panels_product -> producto que usan los tenants (esquemas iam/core dentro)
+--
+-- Requiere haber corrido antes 01-roles.sql. Idempotente vía el idiom
+-- \gexec (solo emite el CREATE DATABASE si todavía no existe).
+--
+-- Uso: psql "$SUPERUSER_URL" -f 02-databases.sql
+
+SELECT 'CREATE DATABASE panels_platform OWNER panels_platform_owner'
+WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'panels_platform')\gexec
+
+SELECT 'CREATE DATABASE panels_product OWNER panels_iam_owner'
+WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'panels_product')\gexec
+
+-- panels_core_owner también necesita poder crear su esquema dentro de panels_product,
+-- aunque el owner nominal de la base sea panels_iam_owner.
+GRANT CREATE ON DATABASE panels_product TO panels_core_owner;
+REVOKE CREATE ON DATABASE panels_product FROM PUBLIC;
+REVOKE CREATE ON DATABASE panels_platform FROM PUBLIC;
+
+-- Postgres otorga CONNECT a PUBLIC por defecto en toda base nueva: sin este
+-- REVOKE, panels_platform_app podría autenticarse contra panels_product (o
+-- viceversa) aunque no tenga privilegios dentro -- justo el cruce que este
+-- diseño busca evitar. Se revoca de PUBLIC y se otorga solo a los roles que
+-- corresponden a cada base.
+REVOKE CONNECT ON DATABASE panels_platform FROM PUBLIC;
+GRANT CONNECT ON DATABASE panels_platform TO panels_platform_owner, panels_platform_app;
+
+REVOKE CONNECT ON DATABASE panels_product FROM PUBLIC;
+GRANT CONNECT ON DATABASE panels_product TO panels_iam_owner, panels_iam_app, panels_core_owner, panels_core_app;
diff --git a/db/provision/03-platform-database.sql b/db/provision/03-platform-database.sql
new file mode 100644
index 0000000..a626a1a
--- /dev/null
+++ b/db/provision/03-platform-database.sql
@@ -0,0 +1,25 @@
+-- PANELS · Postgres · Fase 0 (privilegios dentro de panels_platform)
+--
+-- Correr conectado A LA BASE panels_platform (no a "postgres"), como
+-- superuser/admin: psql "$SUPERUSER_URL/panels_platform" -f 03-platform-database.sql
+--
+-- panels_platform es de un solo esquema (public) porque es un control
+-- plane simple (tenants, platform_users, smtp_settings) usado solo por
+-- las rutas /v1/saas/*. No se subdivide en más esquemas.
+
+REVOKE ALL ON SCHEMA public FROM PUBLIC;
+
+GRANT USAGE, CREATE ON SCHEMA public TO panels_platform_owner;
+GRANT USAGE ON SCHEMA public TO panels_platform_app;
+
+-- Privilegios por defecto para tablas/secuencias que cree panels_platform_owner
+-- (vía Liquibase) a partir de ahora: panels_platform_app solo puede hacer DML,
+-- nunca DDL.
+ALTER DEFAULT PRIVILEGES FOR ROLE panels_platform_owner IN SCHEMA public
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO panels_platform_app;
+ALTER DEFAULT PRIVILEGES FOR ROLE panels_platform_owner IN SCHEMA public
+ GRANT USAGE, SELECT ON SEQUENCES TO panels_platform_app;
+
+-- Objetos que ya existan (si esto corre después de la primera migración de Liquibase)
+GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO panels_platform_app;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO panels_platform_app;
diff --git a/db/provision/04-product-database.sql b/db/provision/04-product-database.sql
new file mode 100644
index 0000000..fedcc86
--- /dev/null
+++ b/db/provision/04-product-database.sql
@@ -0,0 +1,54 @@
+-- PANELS · Postgres · Fase 0 (esquemas y privilegios dentro de panels_product)
+--
+-- Correr conectado A LA BASE panels_product, como superuser/admin:
+-- psql "$SUPERUSER_URL/panels_product" -f 04-product-database.sql
+--
+-- Crea los esquemas iam/core, cada uno con su propio dueño (_owner, usado
+-- por Liquibase) y su propio rol de runtime (_app, solo DML). Se revoca
+-- explícitamente cualquier acceso cruzado entre iam y core: son dos
+-- módulos del monolito modular que NO deben poder leerse entre sí por SQL.
+
+REVOKE ALL ON SCHEMA public FROM PUBLIC;
+
+CREATE SCHEMA IF NOT EXISTS iam AUTHORIZATION panels_iam_owner;
+CREATE SCHEMA IF NOT EXISTS core AUTHORIZATION panels_core_owner;
+
+-- citext (comparación case-insensitive real para curp/rfc, reemplaza el
+-- COLLATE NOCASE de SQLite) se instala DENTRO del esquema core (no en
+-- public, que queda sin USAGE para nadie salvo el propio superuser) --
+-- así el tipo es resoluble por panels_core_owner/panels_core_app con su
+-- search_path=core, sin tener que abrirles el esquema public.
+CREATE EXTENSION IF NOT EXISTS citext SCHEMA core;
+
+-- ===== iam =====
+GRANT USAGE ON SCHEMA iam TO panels_iam_app;
+
+ALTER DEFAULT PRIVILEGES FOR ROLE panels_iam_owner IN SCHEMA iam
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO panels_iam_app;
+ALTER DEFAULT PRIVILEGES FOR ROLE panels_iam_owner IN SCHEMA iam
+ GRANT USAGE, SELECT ON SEQUENCES TO panels_iam_app;
+
+GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA iam TO panels_iam_app;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA iam TO panels_iam_app;
+
+ALTER ROLE panels_iam_owner IN DATABASE panels_product SET search_path = iam;
+ALTER ROLE panels_iam_app IN DATABASE panels_product SET search_path = iam;
+
+-- ===== core =====
+GRANT USAGE ON SCHEMA core TO panels_core_app;
+
+ALTER DEFAULT PRIVILEGES FOR ROLE panels_core_owner IN SCHEMA core
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO panels_core_app;
+ALTER DEFAULT PRIVILEGES FOR ROLE panels_core_owner IN SCHEMA core
+ GRANT USAGE, SELECT ON SEQUENCES TO panels_core_app;
+
+GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA core TO panels_core_app;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
+
+ALTER ROLE panels_core_owner IN DATABASE panels_product SET search_path = core;
+ALTER ROLE panels_core_app IN DATABASE panels_product SET search_path = core;
+
+-- ===== Aislamiento cruzado explícito (defensa en profundidad) =====
+-- Nadie del lado "core" puede tocar "iam" y viceversa, ni por accidente.
+REVOKE ALL ON SCHEMA iam FROM panels_core_app, panels_core_owner, PUBLIC;
+REVOKE ALL ON SCHEMA core FROM panels_iam_app, panels_iam_owner, PUBLIC;
diff --git a/db/provision/05-redis-acl.sh b/db/provision/05-redis-acl.sh
new file mode 100755
index 0000000..1576449
--- /dev/null
+++ b/db/provision/05-redis-acl.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# PANELS · Redis · Fase 0 (usuarios ACL por módulo)
+#
+# Crea dos usuarios ACL (Redis 6+) con acceso restringido por patrón de
+# llave: panels_iam_redis (~iam:*) y panels_core_redis (~core:*). Un
+# prefijo de llave por sí solo NO aísla nada — cualquier cliente con la
+# misma contraseña puede escanear todo el keyspace; el aislamiento real
+# lo da el ACL server-side, no la convención de nombres.
+#
+# Uso:
+# REDIS_ADMIN_URL="redis://:admin-password@host:6379" \
+# IAM_REDIS_PASSWORD="$(openssl rand -base64 32)" \
+# CORE_REDIS_PASSWORD="$(openssl rand -base64 32)" \
+# ./05-redis-acl.sh
+set -euo pipefail
+
+: "${REDIS_ADMIN_URL:?Define REDIS_ADMIN_URL (redis://[:password@]host:port)}"
+: "${IAM_REDIS_PASSWORD:?Define IAM_REDIS_PASSWORD}"
+: "${CORE_REDIS_PASSWORD:?Define CORE_REDIS_PASSWORD}"
+
+# Categorías necesarias para sesiones (hash/string + TTL) y cache
+# (string/hash/set): keyspace cubre DEL/EXPIRE/EXISTS/TTL/SCAN (el patrón de
+# llave sigue acotando el alcance real). Sin @scripting (no se usa Lua/EVAL,
+# menor superficie de ataque) ni @admin/@dangerous.
+ACL_COMMANDS="-@all +@read +@write +@keyspace +@string +@hash +@set +@sortedset +@connection"
+
+# Importante: "reset" debe ir PRIMERO (deja al usuario en blanco); si va
+# después de "on >password" borra la contraseña que se acaba de fijar.
+echo "Creando usuario panels_iam_redis (~iam:*)..."
+redis-cli -u "$REDIS_ADMIN_URL" ACL SETUSER panels_iam_redis \
+ reset \
+ on ">${IAM_REDIS_PASSWORD}" \
+ '~iam:*' '&iam:*' \
+ $ACL_COMMANDS
+
+echo "Creando usuario panels_core_redis (~core:*)..."
+redis-cli -u "$REDIS_ADMIN_URL" ACL SETUSER panels_core_redis \
+ reset \
+ on ">${CORE_REDIS_PASSWORD}" \
+ '~core:*' '&core:*' \
+ $ACL_COMMANDS
+
+redis-cli -u "$REDIS_ADMIN_URL" ACL SAVE >/dev/null 2>&1 || {
+ echo "Aviso: ACL SAVE falló (normal si no hay aclfile configurado; revisar persistencia de ACLs por separado)."
+}
+
+HOST_PORT=$(echo "$REDIS_ADMIN_URL" | sed -E 's#^redis://##; s#^[^@]*@##')
+
+echo "Verificando aislamiento (se espera NOPERM al cruzar de modulo)..."
+IAM_PROBE=$(redis-cli -u "redis://panels_iam_redis:${IAM_REDIS_PASSWORD}@${HOST_PORT}" GET core:probe 2>&1 || true)
+if echo "$IAM_PROBE" | grep -qi "NOPERM"; then
+ echo "OK: panels_iam_redis no puede leer llaves core:*"
+else
+ echo "ADVERTENCIA: no se confirmó NOPERM al probar panels_iam_redis -> core:*. Salida: $IAM_PROBE"
+fi
+
+CORE_PROBE=$(redis-cli -u "redis://panels_core_redis:${CORE_REDIS_PASSWORD}@${HOST_PORT}" GET iam:probe 2>&1 || true)
+if echo "$CORE_PROBE" | grep -qi "NOPERM"; then
+ echo "OK: panels_core_redis no puede leer llaves iam:*"
+else
+ echo "ADVERTENCIA: no se confirmó NOPERM al probar panels_core_redis -> iam:*. Salida: $CORE_PROBE"
+fi
+
+echo "Listo. Guarda IAM_REDIS_PASSWORD/CORE_REDIS_PASSWORD como secrets (REDIS_URL_IAM/REDIS_URL_CORE)."
diff --git a/db/provision/README.md b/db/provision/README.md
new file mode 100644
index 0000000..5c3a909
--- /dev/null
+++ b/db/provision/README.md
@@ -0,0 +1,80 @@
+# Aprovisionamiento de infraestructura (Fase 0)
+
+Scripts para dejar Postgres y Redis listos, en cualquier ambiente (dev local,
+staging o producción en Coolify), antes de correr Liquibase o levantar la API.
+Ver el plan de migración para el diseño completo: dos bases de datos
+(`panels_platform`, `panels_product`) y, dentro de `panels_product`, dos
+esquemas (`iam`, `core`).
+
+## Orden de ejecución (Postgres)
+
+Contra el servidor Postgres del ambiente (uno por dev/staging/producción),
+como usuario superusuario/admin:
+
+```bash
+SUPERUSER_URL="postgresql://postgres:adminpass@HOST:5432/postgres"
+
+# 1. Roles cluster-wide (genera una clave distinta por rol y por ambiente:
+# openssl rand -hex 24). Pasar los valores SIN comillas propias -- el
+# script usa :'var' internamente para escaparlos como literal SQL.
+psql "$SUPERUSER_URL" \
+ -v platform_owner_pw="..." -v platform_app_pw="..." \
+ -v iam_owner_pw="..." -v iam_app_pw="..." \
+ -v core_owner_pw="..." -v core_app_pw="..." \
+ -f 01-roles.sql
+
+# 2. Bases de datos (panels_platform, panels_product)
+psql "$SUPERUSER_URL" -f 02-databases.sql
+
+# 3. Privilegios dentro de panels_platform
+psql "${SUPERUSER_URL%/postgres}/panels_platform" -f 03-platform-database.sql
+
+# 4. Esquemas + privilegios dentro de panels_product
+psql "${SUPERUSER_URL%/postgres}/panels_product" -f 04-product-database.sql
+```
+
+Las conexiones que usará la API (secrets por ambiente):
+
+| Secret | Apunta a | Usuario |
+|---|---|---|
+| `DATABASE_URL_PLATFORM` | `panels_platform` | `panels_platform_app` |
+| `DATABASE_URL_IAM` | `panels_product` (search_path=iam) | `panels_iam_app` |
+| `DATABASE_URL_CORE` | `panels_product` (search_path=core) | `panels_core_app` |
+
+Liquibase (paso de deploy, no en runtime) usa los roles `_owner`:
+`DATABASE_URL_PLATFORM_OWNER`, `DATABASE_URL_IAM_OWNER`, `DATABASE_URL_CORE_OWNER`.
+
+## Redis
+
+```bash
+REDIS_ADMIN_URL="redis://:adminpass@HOST:6379" \
+IAM_REDIS_PASSWORD="$(openssl rand -base64 32)" \
+CORE_REDIS_PASSWORD="$(openssl rand -base64 32)" \
+./05-redis-acl.sh
+```
+
+Secrets resultantes: `REDIS_URL_IAM` (`redis://panels_iam_redis:@HOST:6379`),
+`REDIS_URL_CORE` (`redis://panels_core_redis:@HOST:6379`).
+
+El script verifica automáticamente que cruzar de módulo devuelva `NOPERM`
+(ver "Riesgos y mitigaciones" del plan: un prefijo de llave sin ACL detrás
+no aísla nada).
+
+## Qué falta hacer manualmente en Coolify (staging/producción)
+
+Estos scripts asumen que ya existe un servidor Postgres y un servidor Redis
+accesibles (el recurso gestionado de Coolify por ambiente). El agente no
+tiene acceso a la cuenta de Coolify del usuario, así que:
+
+1. Crear el recurso Postgres gestionado en Coolify para el ambiente.
+2. Crear el recurso Redis gestionado en Coolify para el ambiente.
+3. Correr estos scripts contra ambos usando las credenciales admin que da Coolify.
+4. Cargar los secrets resultantes (`DATABASE_URL_*`, `REDIS_URL_*`) en la
+ configuración del servicio `api` de ese ambiente.
+
+## Desarrollo local
+
+`db/provision/dev-local.sh` reproduce estos mismos pasos contra un Postgres
+y Redis instalados en la máquina de desarrollo (sin Coolify), generando
+`.env.dev-local` con las URLs resultantes — útil para correr la API y los
+tests sin depender de infraestructura externa.
diff --git a/db/provision/dev-local.sh b/db/provision/dev-local.sh
new file mode 100755
index 0000000..0e5e88e
--- /dev/null
+++ b/db/provision/dev-local.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# PANELS · Fase 0 · aprovisiona Postgres + Redis LOCALES (dev), reproduciendo
+# la misma topología de roles/esquemas/ACLs que se usará en Coolify.
+#
+# Requiere: un Postgres corriendo en localhost:5432 accesible como `postgres`
+# vía socket unix (auth peer, el default de un `apt install postgresql`), y
+# un Redis en localhost:6379.
+#
+# Uso: ./db/provision/dev-local.sh
+set -euo pipefail
+cd "$(dirname "$0")"
+
+PG_SUPERUSER_CMD="sudo -u postgres psql -v ON_ERROR_STOP=1"
+PGHOST=127.0.0.1
+PGPORT=5432
+REDIS_HOST=127.0.0.1
+REDIS_PORT=6379
+
+gen_pw() { openssl rand -hex 24; }
+
+PLATFORM_OWNER_PW=$(gen_pw); PLATFORM_APP_PW=$(gen_pw)
+IAM_OWNER_PW=$(gen_pw); IAM_APP_PW=$(gen_pw)
+CORE_OWNER_PW=$(gen_pw); CORE_APP_PW=$(gen_pw)
+IAM_REDIS_PW=$(gen_pw); CORE_REDIS_PW=$(gen_pw)
+
+echo "== 1/5: roles =="
+$PG_SUPERUSER_CMD \
+ -v platform_owner_pw="${PLATFORM_OWNER_PW}" -v platform_app_pw="${PLATFORM_APP_PW}" \
+ -v iam_owner_pw="${IAM_OWNER_PW}" -v iam_app_pw="${IAM_APP_PW}" \
+ -v core_owner_pw="${CORE_OWNER_PW}" -v core_app_pw="${CORE_APP_PW}" \
+ -f 01-roles.sql
+
+echo "== 2/5: bases de datos =="
+$PG_SUPERUSER_CMD -f 02-databases.sql
+
+echo "== 3/5: privilegios panels_platform =="
+$PG_SUPERUSER_CMD -d panels_platform -f 03-platform-database.sql
+
+echo "== 4/5: esquemas + privilegios panels_product =="
+$PG_SUPERUSER_CMD -d panels_product -f 04-product-database.sql
+
+echo "== 5/5: Redis ACLs =="
+REDIS_ADMIN_URL="redis://${REDIS_HOST}:${REDIS_PORT}" \
+IAM_REDIS_PASSWORD="$IAM_REDIS_PW" \
+CORE_REDIS_PASSWORD="$CORE_REDIS_PW" \
+./05-redis-acl.sh
+
+ENV_FILE="../../.env.dev-local"
+cat > "$ENV_FILE" </tmp/verify-out.$$ 2>&1; then
+ ok=1
+ else
+ ok=0
+ fi
+ if [[ "$expect_fail" == "yes" && "$ok" == "0" ]] || [[ "$expect_fail" == "no" && "$ok" == "1" ]]; then
+ echo "OK - $desc"
+ else
+ echo "FAIL - $desc"
+ cat /tmp/verify-out.$$
+ fail=1
+ fi
+ rm -f /tmp/verify-out.$$
+}
+
+: "${DATABASE_URL_PLATFORM:?}"; : "${DATABASE_URL_IAM:?}"; : "${DATABASE_URL_CORE:?}"
+: "${REDIS_URL_IAM:?}"; : "${REDIS_URL_CORE:?}"
+
+extract_pw() { echo "$1" | sed -E 's#[a-zA-Z]+://[^:]+:([^@]+)@.*#\1#'; }
+PLATFORM_HOST=$(echo "$DATABASE_URL_PLATFORM" | sed -E 's#.*@([^/]+)/.*#\1#')
+
+echo "== Postgres: DML normal funciona =="
+check "iam_app puede hacer SELECT en su esquema" no psql "$DATABASE_URL_IAM" -c "SELECT 1;"
+check "core_app puede hacer SELECT en su esquema" no psql "$DATABASE_URL_CORE" -c "SELECT 1;"
+
+echo "== Postgres: aislamiento cruzado =="
+check "iam_app NO puede leer schema core" yes psql "$DATABASE_URL_IAM" -c "SELECT * FROM core.pg_tables LIMIT 1;"
+check "core_app NO puede leer schema iam" yes psql "$DATABASE_URL_CORE" -c "SELECT 1 FROM iam.pg_am LIMIT 1;"
+check "iam_app NO puede hacer DDL (solo _owner puede)" yes psql "$DATABASE_URL_IAM" -c "CREATE TABLE iam.probe_$$(id int);"
+
+PLATFORM_APP_PW=$(extract_pw "$DATABASE_URL_PLATFORM")
+IAM_APP_PW=$(extract_pw "$DATABASE_URL_IAM")
+check "platform_app NO puede CONNECT a panels_product" yes \
+ psql "postgresql://panels_platform_app:${PLATFORM_APP_PW}@${PLATFORM_HOST%/*}/panels_product" -c "SELECT 1;"
+check "iam_app NO puede CONNECT a panels_platform" yes \
+ psql "postgresql://panels_iam_app:${IAM_APP_PW}@${PLATFORM_HOST%/*}/panels_platform" -c "SELECT 1;"
+
+echo "== Redis: ACL por módulo =="
+IAM_REDIS_HOST=$(echo "$REDIS_URL_IAM" | sed -E 's#redis://[^@]+@##')
+IAM_REDIS_PW=$(extract_pw "$REDIS_URL_IAM")
+CORE_REDIS_PW=$(extract_pw "$REDIS_URL_CORE")
+check "panels_iam_redis puede escribir iam:*" no \
+ redis-cli -u "redis://panels_iam_redis:${IAM_REDIS_PW}@${IAM_REDIS_HOST}" SET iam:verify:probe 1
+check "panels_iam_redis NO puede leer core:* (NOPERM)" no \
+ bash -c "redis-cli -u 'redis://panels_iam_redis:${IAM_REDIS_PW}@${IAM_REDIS_HOST}' GET core:probe 2>&1 | grep -q NOPERM"
+check "panels_core_redis NO puede leer iam:* (NOPERM)" no \
+ bash -c "redis-cli -u 'redis://panels_core_redis:${CORE_REDIS_PW}@${IAM_REDIS_HOST}' GET iam:probe 2>&1 | grep -q NOPERM"
+
+echo ""
+if [[ "$fail" == "0" ]]; then
+ echo "Todas las verificaciones de aislamiento pasaron."
+else
+ echo "Hay verificaciones fallidas -- revisar antes de continuar."
+ exit 1
+fi
diff --git a/db/update.sh b/db/update.sh
index d8c22d7..846b07a 100755
--- a/db/update.sh
+++ b/db/update.sh
@@ -1,38 +1,72 @@
#!/usr/bin/env bash
+# PANELS · aplica los changelogs de Liquibase contra Postgres.
+#
+# Requiere las credenciales del rol "_owner" de cada módulo (nunca el rol
+# "_app" -- ese es solo para runtime, sin privilegios de DDL). Se leen de
+# variables de entorno con el mismo nombre que usan los secrets de la app:
+# DATABASE_URL_PLATFORM_OWNER (jdbc: panels_platform)
+# DATABASE_URL_IAM_OWNER (jdbc: panels_product, schema iam)
+# DATABASE_URL_CORE_OWNER (jdbc: panels_product, schema core)
+#
+# Formato esperado: postgresql://usuario:password@host:puerto/basededatos
+#
+# Uso:
+# set -a && source .env.dev-local && set +a && ./db/update.sh all
+# ./db/update.sh core --context-filter=dev
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
"$ROOT/db/bootstrap-tools.sh"
LIQUIBASE="$ROOT/db/tools/liquibase/liquibase"
-SQLITE_JDBC="$ROOT/db/tools/sqlite-jdbc.jar"
-
-mkdir -p "$ROOT/data"
+PG_JDBC="$ROOT/db/tools/postgresql-jdbc.jar"
TARGET="${1:-all}"
+shift || true
+EXTRA_ARGS=("$@")
+
+# postgresql://user:pass@host:port/db -> jdbc:postgresql://host:port/db + user/pass
+parse_url() {
+ local url="$1"
+ local rest="${url#postgresql://}"
+ local userinfo="${rest%%@*}"
+ local hostpart="${rest#*@}"
+ PARSED_USER="${userinfo%%:*}"
+ PARSED_PASS="${userinfo#*:}"
+ PARSED_JDBC="jdbc:postgresql://${hostpart}"
+}
run_one() {
local name="$1"
- local dir="$ROOT/db/$name"
- local db="$ROOT/data/${name}.db"
- echo "==> Liquibase update: $name ($db)"
+ local var="DATABASE_URL_${name^^}_OWNER"
+ local url="${!var:-}"
+ if [[ -z "$url" ]]; then
+ echo "Falta la variable de entorno $var (credenciales del rol _owner de $name)" >&2
+ exit 1
+ fi
+ parse_url "$url"
+ echo "==> Liquibase update: $name ($PARSED_JDBC)"
(
- cd "$dir"
+ cd "$ROOT/db/$name"
"$LIQUIBASE" \
--defaultsFile=liquibase.properties \
- --classpath="$SQLITE_JDBC" \
- --url="jdbc:sqlite:$db" \
- update
+ --classpath="$PG_JDBC" \
+ --url="$PARSED_JDBC" \
+ --username="$PARSED_USER" \
+ --password="$PARSED_PASS" \
+ update "${EXTRA_ARGS[@]}"
)
}
case "$TARGET" in
- app) run_one app ;;
platform) run_one platform ;;
+ iam) run_one iam ;;
+ core) run_one core ;;
all)
- run_one app
run_one platform
+ run_one iam
+ run_one core
;;
*)
- echo "Usage: $0 [all|app|platform]" >&2
+ echo "Usage: $0 [all|platform|iam|core] [-- liquibase-args...]" >&2
exit 1
;;
esac