From b87b0205a89f34510279913f9193e03986f41f11 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 20:00:49 +0000 Subject: [PATCH 01/13] db: migrar infraestructura de BD a Postgres (fase 0-1) - Fase 0: scripts de aprovisionamiento (db/provision/) para roles, dos bases de datos separadas (panels_platform / panels_product con esquemas iam+core) y ACLs de Redis por modulo, con verificacion automatizada de aislamiento (verify-isolation.sh) y setup local reproducible (dev-local.sh). - Fase 1: changelogs de Liquibase reescritos para Postgres (db/platform, db/iam, db/core reemplazan db/app + los changesets SQLite de platform). Baseline como estado final (no replay literal), tipos traducidos (IDENTITY, TIMESTAMPTZ/DATE, NUMERIC, BOOLEAN, CITEXT), contexts dev vs. schema/catalogos, RLS por tenant_id como defensa en profundidad, uploaded_by/created_by como snapshot desnormalizado (sin FK hacia iam). - Migraciones ya no corren en el arranque de la API: paso explicito de deploy via db/update.sh con credenciales _owner. Verificado end-to-end contra Postgres 16 + Redis local. Co-authored-by: alberto.martinez --- db/README.md | 77 ++- db/app/changelog-master.xml | 13 - db/app/changesets/001-baseline.sql | 254 --------- db/app/changesets/002-tenant-id.sql | 43 -- .../changesets/003-must-change-password.sql | 11 - db/app/changesets/004-document-catalog.sql | 40 -- db/app/changesets/005-payroll-week.sql | 164 ------ db/app/liquibase.properties | 4 - db/bootstrap-tools.sh | 8 +- db/core/changelog-master.xml | 16 + db/core/changesets/001-baseline.sql | 482 ++++++++++++++++++ db/core/changesets/002-tenant-settings.sql | 17 + db/core/changesets/003-seed-catalogs.sql | 73 +++ db/core/changesets/004-seed-dev-data.sql | 31 ++ db/core/changesets/005-rls.sql | 71 +++ db/core/liquibase.properties | 9 + db/iam/changelog-master.xml | 12 + db/iam/changesets/001-baseline.sql | 92 ++++ db/iam/changesets/002-rls.sql | 28 + db/iam/liquibase.properties | 11 + db/platform/changelog-master.xml | 18 +- db/platform/changesets/001-baseline.sql | 66 +++ db/platform/changesets/001-init.sql | 20 - .../changesets/002-seed-arctec-tenant.sql | 7 - db/platform/changesets/003-tenant-license.sql | 16 - .../changesets/004-tenant-status-expand.sql | 30 -- db/platform/changesets/005-contact-access.sql | 16 - .../changesets/006-tenant-code-date.sql | 7 - db/platform/changesets/007-smtp-settings.sql | 18 - db/platform/liquibase.properties | 12 +- db/provision/01-roles.sql | 51 ++ db/provision/02-databases.sql | 33 ++ db/provision/03-platform-database.sql | 25 + db/provision/04-product-database.sql | 54 ++ db/provision/05-redis-acl.sh | 64 +++ db/provision/README.md | 80 +++ db/provision/dev-local.sh | 62 +++ db/provision/verify-isolation.sh | 67 +++ db/update.sh | 60 ++- 39 files changed, 1471 insertions(+), 691 deletions(-) delete mode 100644 db/app/changelog-master.xml delete mode 100644 db/app/changesets/001-baseline.sql delete mode 100644 db/app/changesets/002-tenant-id.sql delete mode 100644 db/app/changesets/003-must-change-password.sql delete mode 100644 db/app/changesets/004-document-catalog.sql delete mode 100644 db/app/changesets/005-payroll-week.sql delete mode 100644 db/app/liquibase.properties create mode 100644 db/core/changelog-master.xml create mode 100644 db/core/changesets/001-baseline.sql create mode 100644 db/core/changesets/002-tenant-settings.sql create mode 100644 db/core/changesets/003-seed-catalogs.sql create mode 100644 db/core/changesets/004-seed-dev-data.sql create mode 100644 db/core/changesets/005-rls.sql create mode 100644 db/core/liquibase.properties create mode 100644 db/iam/changelog-master.xml create mode 100644 db/iam/changesets/001-baseline.sql create mode 100644 db/iam/changesets/002-rls.sql create mode 100644 db/iam/liquibase.properties create mode 100644 db/platform/changesets/001-baseline.sql delete mode 100644 db/platform/changesets/001-init.sql delete mode 100644 db/platform/changesets/002-seed-arctec-tenant.sql delete mode 100644 db/platform/changesets/003-tenant-license.sql delete mode 100644 db/platform/changesets/004-tenant-status-expand.sql delete mode 100644 db/platform/changesets/005-contact-access.sql delete mode 100644 db/platform/changesets/006-tenant-code-date.sql delete mode 100644 db/platform/changesets/007-smtp-settings.sql create mode 100644 db/provision/01-roles.sql create mode 100644 db/provision/02-databases.sql create mode 100644 db/provision/03-platform-database.sql create mode 100644 db/provision/04-product-database.sql create mode 100755 db/provision/05-redis-acl.sh create mode 100644 db/provision/README.md create mode 100755 db/provision/dev-local.sh create mode 100755 db/provision/verify-isolation.sh 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 From 493829d028c94ef6bd4e8d126c65d54c6cdb4a53 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 20:47:45 +0000 Subject: [PATCH 02/13] api: migrar todo el backend de SQLite a Postgres + Redis (fase 2-4e) Fase 2 (driver): - api/pg.ts: adaptador delgado sobre postgres.js (prepare/get/all/run, placeholders ? -> $n, withTenant con set_config para RLS), con parsers de tipo custom (numeric/date/timestamp(tz)/bigint) para que el resto del codigo heredado de SQLite (fechas/montos como string, ids como number) siga funcionando sin reescribir cada call-site a mano. - api/platform_db.ts, api/iam_db.ts (nuevo), api/db.ts: pools separados por base/esquema (panels_platform, panels_product.iam, panels_product.core), owner pool para bootstrap/scripts/lookups administrativos que cruzan tenant a proposito. - api/redis.ts: clientes iam/core separados (ACL panels_iam_redis / panels_core_redis). - api/sessions.ts + auth.ts: sesiones ahora en Redis (cookie = id opaco, no HMAC autocontenido); revocacion real (logout, cambio de password). - api/storage.ts (Fase 4c): documentos/PDFs via Contabo Object Storage (S3), con fallback a disco local si no hay credenciales S3 (dev). - api/scope.ts: middleware withCoreScope/requireCoreAuth que abre la transaccion con app.tenant_id fijado (RLS) para cada request. - api/cache.ts (Fase 4e): cache Redis con tenant_id obligatorio en la llave; aplicado a /v1/catalogs. Fase 3 (reescritura SQL, ~80 endpoints en main.ts/companies.ts/budget.ts/ payroll.ts/payroll_http.ts/excel.ts/saas.ts/smtp.ts): - Todo async/await, sintaxis Postgres (COALESCE, ~ regex, ON CONFLICT, now()/current_date, booleanos reales, RETURNING via lastInsertId()). - IDOR cross-tenant cerrado: GET/PATCH /v1/projects/:id, /v1/workers/:id ya no dependen de que el handler recuerde el WHERE tenant_id -- Row Level Security lo hace estructuralmente (verificado con un segundo tenant real: 404 en vez de fuga de datos). - API key ya no ve todos los tenants: ahora exige X-Tenant-Id explicito. Fase 3b (tests): api/test_helpers.ts corre cada test en una transaccion que siempre se revierte, contra el mismo baseline de Liquibase que produccion (ya no un esquema SQLite escrito a mano). payroll_test.ts reescrito con fixtures reales; 11/11 pasan contra Postgres. Fase 4 (IAM/RBAC): iam.roles/permissions/role_permissions formalizados (ver db/iam ya en fase 1); uploaded_by/created_by ahora son snapshot desnormalizado (uploaded_by_id/name); seed() en runtime eliminado, reemplazado por scripts/bootstrap-admin.ts (one-shot). Fase 4d (zona horaria): nuevo endpoint /v1/configuracion (GET/PUT), PAYROLL_TZ hardcodeado reemplazado por tenant_settings.timezone, document_validity.ts ya no usa new Date() crudo. Verificado end-to-end contra Postgres+Redis reales: login, sesiones, catalogos con cache, alta de trabajador, subida/descarga de documento cifrado, y el fix de IDOR probado con un segundo tenant real (403/404 en vez de fuga de datos). Co-authored-by: alberto.martinez --- api/auth.ts | 164 +++++--- api/budget.ts | 81 ++-- api/cache.ts | 57 +++ api/companies.ts | 221 ++++++----- api/config.ts | 71 +++- api/crypto.ts | 42 +- api/db.ts | 339 ++++++---------- api/deno.json | 7 +- api/deno.lock | 374 +++++++++++++++--- api/docs_crypto.ts | 6 +- api/document_validity.ts | 14 +- api/document_validity_test.ts | 6 +- api/excel.ts | 113 +++--- api/iam_db.ts | 72 ++++ api/liquibase.ts | 67 ---- api/mail.ts | 8 +- api/main.ts | 662 ++++++++++++++++++-------------- api/mx.ts | 2 +- api/payroll.ts | 461 +++++++++++----------- api/payroll_http.ts | 193 +++++----- api/payroll_test.ts | 487 +++++++++++------------ api/pdf.ts | 46 +-- api/pg.ts | 138 +++++++ api/platform_db.ts | 111 ++---- api/redis.ts | 51 +++ api/saas.ts | 201 ++++++---- api/scope.ts | 26 ++ api/scripts/bootstrap-admin.ts | 103 +++++ api/seed_lista.ts | 153 ++++---- api/sessions.ts | 98 +++++ api/smtp.ts | 42 +- api/storage.ts | 100 +++++ api/test_helpers.ts | 54 +++ scripts/migrate-tenant-codes.ts | 129 ------- 34 files changed, 2761 insertions(+), 1938 deletions(-) create mode 100644 api/cache.ts create mode 100644 api/iam_db.ts delete mode 100644 api/liquibase.ts create mode 100644 api/pg.ts create mode 100644 api/redis.ts create mode 100644 api/scope.ts create mode 100644 api/scripts/bootstrap-admin.ts create mode 100644 api/sessions.ts create mode 100644 api/storage.ts create mode 100644 api/test_helpers.ts delete mode 100644 scripts/migrate-tenant-codes.ts diff --git a/api/auth.ts b/api/auth.ts index f0a79b4..443c9e1 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -1,9 +1,10 @@ import type { Context, Next } from "hono"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; -import { getDb } from "./db.ts"; +import { withIamTenant, findUserByUsernameAnyTenant } from "./iam_db.ts"; import { getPlatformDb } from "./platform_db.ts"; import { config } from "./config.ts"; -import { b64urlJson, b64urlJsonParse, hmacSign, hmacVerify, hashPassword, verifyPassword } from "./crypto.ts"; +import { createSession, getSession, revokeSession, revokeAllSessionsForUser } from "./sessions.ts"; +import { hashPassword, verifyPassword } from "./crypto.ts"; export type AuthRealm = "app" | "platform"; export type AuthRole = "platform_admin" | "tenant_admin" | "user"; @@ -22,34 +23,33 @@ export type AuthUser = { must_change_password: boolean; }; -const USER_SELECT = `u.id, u.username, u.display_name, u.company_id, u.tenant_id, u.role, - COALESCE(u.must_change_password, 0) AS must_change_password, - c.code AS company_code, c.name AS company_name, c.kind AS company_kind`; - -type SessionPayload = { uid: number; exp: number; realm?: AuthRealm }; - const COOKIE = "po_session"; -const TTL = 60 * 60 * 24 * 7; +const TTL_SECONDS = 60 * 60 * 24 * 7; -export async function createSessionCookie(c: Context, userId: number, realm: AuthRealm = "app") { - const payload: SessionPayload = { uid: userId, exp: Date.now() + TTL * 1000, realm }; - const body = b64urlJson(payload); - const sig = await hmacSign(config.sessionSecret, body); - setCookie(c, COOKIE, `${body}.${sig}`, { +export async function createSessionCookie( + c: Context, + userId: number, + realm: AuthRealm = "app", + tenantId: number | null = null, +) { + const id = await createSession(userId, realm, tenantId); + setCookie(c, COOKIE, id, { httpOnly: true, path: "/", sameSite: "Lax", secure: config.cookieSecure, - maxAge: TTL, + maxAge: TTL_SECONDS, }); } -export function clearSession(c: Context) { +export async function clearSession(c: Context) { + const id = getCookie(c, COOKIE); + if (id) await revokeSession(id); deleteCookie(c, COOKIE, { path: "/" }); } function asAppUser(row: Record): AuthUser { - const role = (row.role as string) || "user"; + const role = (row.role_code as string) || "user"; return { id: Number(row.id), username: String(row.username), @@ -82,35 +82,35 @@ function asPlatformUser(row: { id: number; username: string; display_name: strin } async function userFromCookie(c: Context): Promise { - const raw = getCookie(c, COOKIE); - if (!raw || !raw.includes(".")) return null; - const [body, sig] = raw.split("."); - if (!await hmacVerify(config.sessionSecret, body, sig)) return null; - let payload: SessionPayload; - try { - payload = b64urlJsonParse(body); - } catch { - return null; - } - if (payload.exp < Date.now()) return null; - const realm: AuthRealm = payload.realm === "platform" ? "platform" : "app"; + const id = getCookie(c, COOKIE); + if (!id) return null; + const session = await getSession(id); + if (!session) return null; - if (realm === "platform") { + if (session.realm === "platform") { const pdb = await getPlatformDb(); - const row = pdb.prepare( + const row = await pdb.prepare( `SELECT id, username, display_name FROM platform_users WHERE id = ? AND status = 'activo'`, - ).get(payload.uid) as { id: number; username: string; display_name: string } | undefined; + ).get(session.userId) as { id: number; username: string; display_name: string } | undefined; return row ? asPlatformUser(row) : null; } - const db = await getDb(); - const row = db.prepare( - `SELECT ${USER_SELECT} - FROM users u LEFT JOIN companies c ON c.id = u.company_id - WHERE u.id = ?`, - ).get(payload.uid) as Record | undefined; - return row ? asAppUser(row) : null; + return await getFreshAppUser(session.userId, session.tenantId); +} + +/** Relee un usuario app (iam) fresco de la base y lo enriquece con su + * empresa (core) -- usado tras login y tras cambiar contraseña. */ +export async function getFreshAppUser(userId: number, tenantId: number | null): Promise { + const row = await withIamTenant(tenantId, async (db) => { + return await db.prepare( + `SELECT id, username, password_hash, display_name, company_id, tenant_id, role_code, + must_change_password, email FROM users WHERE id = ?`, + ).get(userId); + }); + if (!row) return null; + const company = await enrichWithCompanyViaCore((row.company_id as number) ?? null); + return asAppUser({ ...row, ...company }); } function apiKeyOk(c: Context): boolean { @@ -124,6 +124,15 @@ function apiKeyOk(c: Context): boolean { export async function requireAuth(c: Context, next: Next) { if (apiKeyOk(c)) { + // La API key ya NO otorga visibilidad cruzada de todos los tenants + // (era un hallazgo crítico de la revisión de seguridad): ahora exige un + // tenant explícito por header, y las políticas de RLS son fail-closed + // si no se fija -- sin X-Tenant-Id válido, la API key no ve nada. + const tenantHeader = c.req.header("x-tenant-id") ?? ""; + const tenantId = Number(tenantHeader); + if (!tenantHeader || !Number.isInteger(tenantId) || tenantId <= 0) { + return c.json({ error: "X-API-Key requiere X-Tenant-Id" }, 400); + } c.set("user", { id: 0, username: "api", @@ -132,7 +141,7 @@ export async function requireAuth(c: Context, next: Next) { company_code: null, company_name: null, company_kind: null, - tenant_id: null, + tenant_id: tenantId, role: "tenant_admin", realm: "app", must_change_password: false, @@ -166,17 +175,21 @@ export async function login(username: string, password: string): Promise & { password_hash: string }) | undefined; - if (appRow && await verifyPassword(pass, appRow.password_hash)) { - const authUser = asAppUser(appRow); - const { tenantAccessBlocked } = await import("./saas.ts"); + // username es único globalmente (no por tenant) -- se resuelve con el + // pool que omite RLS (ver iam_db.ts#findUserByUsernameAnyTenant); recién + // después de esto se conoce el tenant_id para todo lo demás. + const appRow = await findUserByUsernameAnyTenant(user); + if (appRow && await verifyPassword(pass, appRow.password_hash as string)) { + const authUser = await withIamTenant( + appRow.tenant_id == null ? null : Number(appRow.tenant_id), + async (db) => { + const company = await enrichWithCompanyViaCore(Number(appRow.company_id) || null); + return asAppUser({ ...appRow, ...company }); + }, + ); const pdb = await getPlatformDb(); - const blocked = tenantAccessBlocked(pdb, authUser.tenant_id); + const { tenantAccessBlocked } = await import("./saas.ts"); + const blocked = await tenantAccessBlocked(pdb, authUser.tenant_id); if (blocked) { throw Object.assign(new Error(blocked), { code: "TENANT_BLOCKED" }); } @@ -184,7 +197,7 @@ export async function login(username: string, password: string): Promisecore; usa la conexión core con el rol de + * runtime normal (companies no está sujeta a RLS por-fila salvo por + * tenant_id, así que basta con conocer el tenant ya resuelto). */ +async function enrichWithCompanyViaCore(companyId: number | null) { + if (companyId == null) return { company_code: null, company_name: null, company_kind: null }; + const { getCoreDb } = await import("./db.ts"); + const db = await getCoreDb(); + const row = await db.prepare("SELECT code, name, kind FROM companies WHERE id = ?").get( + companyId, + ); + return { + company_code: (row?.code as string) ?? null, + company_name: (row?.name as string) ?? null, + company_kind: (row?.kind as string) ?? null, + }; +} + export async function changePassword( userId: number, + tenantId: number | null, currentPassword: string, newPassword: string, ): Promise<{ error?: string }> { const next = (newPassword ?? "").trim(); if (next.length < 8) return { error: "La nueva contraseña debe tener al menos 8 caracteres" }; - const db = await getDb(); - const row = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId) as - | { password_hash: string } - | undefined; - if (!row) return { error: "Usuario no encontrado" }; - if (!await verifyPassword(currentPassword, row.password_hash)) { - return { error: "Contraseña actual incorrecta" }; - } - const hash = await hashPassword(next); - db.prepare("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?").run(hash, userId); - return {}; + return await withIamTenant(tenantId, async (db) => { + const row = await db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId) as + | { password_hash: string } + | undefined; + if (!row) return { error: "Usuario no encontrado" }; + if (!await verifyPassword(currentPassword, row.password_hash)) { + return { error: "Contraseña actual incorrecta" }; + } + const hash = await hashPassword(next); + await db.prepare( + "UPDATE users SET password_hash = ?, must_change_password = false WHERE id = ?", + ).run(hash, userId); + // Cambiar password revoca TODAS las demás sesiones activas de este + // usuario -- antes (cookie HMAC stateless) esto era imposible; ahora + // sí, porque el estado real vive en Redis (ver sessions.ts). + await revokeAllSessionsForUser(userId, "app"); + return {}; + }); } diff --git a/api/budget.ts b/api/budget.ts index 95e3e8f..4ef4c81 100644 --- a/api/budget.ts +++ b/api/budget.ts @@ -1,9 +1,5 @@ import * as XLSX from "xlsx"; -import type { Database } from "@db/sqlite"; - -function lastId(database: Database): number { - return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); -} +import type { Db } from "./db.ts"; export const BUDGET_IVA = 0.16; @@ -266,7 +262,7 @@ function looksOpus(rows: unknown[][], headerAt: number, map: ColMap) { return false; } -function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget { +function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): Omit { const groups: ParsedGroup[] = []; const items: ParsedBudgetItem[] = []; let skipped = 0; @@ -326,7 +322,7 @@ function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedB return { groups, items, skipped }; } -function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget { +function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): Omit { const groups: ParsedGroup[] = []; const items: ParsedBudgetItem[] = []; let skipped = 0; @@ -428,7 +424,7 @@ function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): Parsed return { groups, items, skipped }; } -function parseFlatRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget { +function parseFlatRows(rows: unknown[][], headerAt: number, map: ColMap): Omit { const groups: ParsedGroup[] = []; const items: ParsedBudgetItem[] = []; let skipped = 0; @@ -645,14 +641,12 @@ function compareWbs(a: string, b: string) { return a.localeCompare(b, undefined, { numeric: true }); } -export function listBudget(database: Database, projectId: number) { - const chapterCols = (database.prepare("PRAGMA table_info(budget_chapters)").all() as { name: string }[]).map((c) => c.name); - const wbsExpr = chapterCols.includes("wbs") ? "COALESCE(c.wbs, '') AS wbs" : "'' AS wbs"; - const chapters = database.prepare( - `SELECT c.id, c.project_id, c.parent_id, c.code, c.name, ${wbsExpr}, c.sort_order +export async function listBudget(database: Db, projectId: number) { + const chapters = await database.prepare( + `SELECT c.id, c.project_id, c.parent_id, c.code, c.name, COALESCE(c.wbs, '') AS wbs, c.sort_order FROM budget_chapters c WHERE c.project_id = ? ORDER BY c.sort_order, c.id`, ).all(projectId) as ChapterRow[]; - const rawItems = database.prepare( + const rawItems = await database.prepare( `SELECT i.*, ch.name AS chapter_name, ch.code AS chapter_code FROM budget_items i LEFT JOIN budget_chapters ch ON ch.id = i.chapter_id @@ -750,64 +744,47 @@ export function listBudget(database: Database, projectId: number) { }; } -function insertChapter( - database: Database, +async function insertChapter( + database: Db, projectId: number, parentId: number | null, code: string, name: string, wbs: string, sort: number, -) { - const cols = (database.prepare("PRAGMA table_info(budget_chapters)").all() as { name: string }[]).map((c) => c.name); - if (cols.includes("wbs")) { - database.prepare( - "INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)", - ).run(projectId, parentId, code, name, wbs, sort); - } else { - database.prepare( - "INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)", - ).run(projectId, parentId, code, name, sort); - } - return lastId(database); +): Promise { + await database.prepare( + "INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)", + ).run(projectId, parentId, code, name, wbs, sort); + return await database.lastInsertId(); } -export function replaceBudgetFromParsed(database: Database, projectId: number, parsed: ParsedBudget) { - database.prepare("DELETE FROM budget_items WHERE project_id = ?").run(projectId); - database.prepare("DELETE FROM budget_chapters WHERE project_id = ?").run(projectId); +export async function replaceBudgetFromParsed(database: Db, projectId: number, parsed: ParsedBudget): Promise { + await database.prepare("DELETE FROM budget_items WHERE project_id = ?").run(projectId); + await database.prepare("DELETE FROM budget_chapters WHERE project_id = ?").run(projectId); const groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs)); const ids = new Map(); let sort = 1; for (const group of groups) { const parentId = group.parentWbs ? ids.get(group.parentWbs) || null : null; - const id = insertChapter(database, projectId, parentId, group.code, group.name, group.wbs, sort); + const id = await insertChapter(database, projectId, parentId, group.code, group.name, group.wbs, sort); ids.set(group.wbs, id); sort++; } - const itemCols = (database.prepare("PRAGMA table_info(budget_items)").all() as { name: string }[]).map((c) => c.name); - const hasWbs = itemCols.includes("wbs"); let itemSort = 1; for (const item of parsed.items) { const chapterId = item.parentWbs ? ids.get(item.parentWbs) || null : null; const amount = Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100; - if (hasWbs) { - database.prepare( - `INSERT INTO budget_items - (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs); - } else { - database.prepare( - `INSERT INTO budget_items - (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort); - } + await database.prepare( + `INSERT INTO budget_items + (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs); itemSort++; } } -export function replaceBudgetFromItems(database: Database, projectId: number, parsed: ParsedBudgetItem[]) { +export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise { const groups: ParsedGroup[] = []; const seen = new Set(); for (const item of parsed) { @@ -822,7 +799,7 @@ export function replaceBudgetFromItems(database: Database, projectId: number, pa name: item.chapter || "General", }); } - replaceBudgetFromParsed(database, projectId, { format: "flat", groups, items: parsed, skipped: 0 }); + await replaceBudgetFromParsed(database, projectId, { format: "flat", groups, items: parsed, skipped: 0 }); } export function lineAmount(quantity: number, unitPrice: number) { @@ -925,7 +902,7 @@ export function previewBudgetExcel(bytes: Uint8Array): BudgetPreview { }; } -export function importBudgetExcel(database: Database, projectId: number, bytes: Uint8Array): BudgetImportReport { +export async function importBudgetExcel(database: Db, projectId: number, bytes: Uint8Array): Promise { const read = readBudgetSheet(bytes); if ("error" in read) { return { replaced: false, chapters: 0, inserted: 0, skipped: 0, errors: [{ row: 0, messages: [read.error] }] }; @@ -940,7 +917,7 @@ export function importBudgetExcel(database: Database, projectId: number, bytes: errors: [{ row: 0, messages: ["No se encontraron partidas. Use la plantilla o un Excel con CLAVE, DESCRIPCION, UNIDAD, CANTIDAD y PRECIO."] }], }; } - replaceBudgetFromParsed(database, projectId, parsed); + await replaceBudgetFromParsed(database, projectId, parsed); const subtotal = Math.round(parsed.items.reduce((sum, item) => sum + Number(item.amount || 0), 0) * 100) / 100; const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100; const totals = { @@ -950,7 +927,7 @@ export function importBudgetExcel(database: Database, projectId: number, bytes: item_count: parsed.items.length, }; // El monto de contrato del proyecto refleja el subtotal del presupuesto importado. - database.prepare("UPDATE projects SET contract_amount = ? WHERE id = ?").run(subtotal, projectId); + await database.prepare("UPDATE projects SET contract_amount = ? WHERE id = ?").run(subtotal, projectId); return { replaced: true, chapters: parsed.groups.length, diff --git a/api/cache.ts b/api/cache.ts new file mode 100644 index 0000000..5be7743 --- /dev/null +++ b/api/cache.ts @@ -0,0 +1,57 @@ +import { getCoreRedis, getIamRedis } from "./redis.ts"; + +/** + * Estrategia de cache con Redis (Fase 4e). + * + * Regla no negociable: toda llave que contenga datos de negocio DEBE + * incluir el tenant_id (ver cacheKeyCore). Una llave global para datos + * por-tenant reintroduce el mismo IDOR cross-tenant que Row Level Security + * (Fase 4b) se propuso cerrar -- solo que en Redis en vez de Postgres. + * + * Redis no es fuente de verdad de nada: si una llave expira o se pierde, + * la siguiente lectura recalcula desde Postgres. Por eso alcanza con TTL + * corto para datos de lectura frecuente/escritura poco frecuente + * (catálogos, agregados de listados) y no hace falta invalidación + * explícita en la mayoría de los casos. + */ + +export function cacheKeyCore(tenantId: number | null, ...parts: (string | number)[]): string { + return `core:cache:tenant:${tenantId ?? "none"}:${parts.join(":")}`; +} + +export function cacheKeyIam(tenantId: number | null, ...parts: (string | number)[]): string { + return `iam:cache:tenant:${tenantId ?? "none"}:${parts.join(":")}`; +} + +async function cached( + redis: Awaited>, + key: string, + ttlSeconds: number, + compute: () => Promise, +): Promise { + const hit = await redis.get(key).catch(() => null); + if (hit != null) { + try { + return JSON.parse(hit) as T; + } catch { + // llave corrupta/formato viejo -- recalcular sin fallar el request + } + } + const value = await compute(); + await redis.set(key, JSON.stringify(value), { EX: ttlSeconds }).catch(() => {}); + return value; +} + +export async function cacheCore(key: string, ttlSeconds: number, compute: () => Promise): Promise { + const redis = await getCoreRedis(); + return await cached(redis, key, ttlSeconds, compute); +} + +export async function cacheIam(key: string, ttlSeconds: number, compute: () => Promise): Promise { + const redis = await getIamRedis(); + return await cached(redis, key, ttlSeconds, compute); +} + +export async function invalidateCore(key: string): Promise { + await (await getCoreRedis()).del(key).catch(() => {}); +} diff --git a/api/companies.ts b/api/companies.ts index c7cda4b..dae2f91 100644 --- a/api/companies.ts +++ b/api/companies.ts @@ -1,8 +1,4 @@ -import type { Database } from "@db/sqlite"; - -function lastId(database: Database): number { - return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); -} +import type { Db } from "./db.ts"; export type Company = { id: number; @@ -64,36 +60,41 @@ function normRfc(value: unknown): string { return trimText(value).toUpperCase().replace(/\s+/g, ""); } -export function listCompanies(database: Database, tenantId?: number | null): Company[] { +export async function listCompanies(database: Db, tenantId?: number | null): Promise { + // El filtro tenant_id aquí es defensa adicional/legibilidad -- el + // aislamiento real ya lo garantiza Row Level Security sobre la conexión + // acotada por withCoreScope (ver db/core/changesets/005-rls.sql). const where = tenantId != null ? "WHERE c.tenant_id = ?" : ""; const params = tenantId != null ? [tenantId] : []; - return database.prepare( + return await database.prepare( `SELECT c.*, p.code AS parent_code, p.name AS parent_name, (SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count FROM companies c LEFT JOIN companies p ON p.id = c.parent_id ${where} - ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, c.name COLLATE NOCASE`, + ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, LOWER(c.name)`, ).all(...params) as Company[]; } -export function companyById(database: Database, id: number): Company | undefined { - return database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as Company | undefined; -} - -export function companyByCode(database: Database, code: string): Company | undefined { - return database.prepare("SELECT * FROM companies WHERE code = ?").get(normCompanyCode(code)) as +export async function companyById(database: Db, id: number): Promise { + return await database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as | Company | undefined; } -export function principalCompany(database: Database, tenantId?: number | null): Company | undefined { +export async function companyByCode(database: Db, code: string): Promise { + return await database.prepare("SELECT * FROM companies WHERE code = ?").get( + normCompanyCode(code), + ) as Company | undefined; +} + +export async function principalCompany(database: Db, tenantId?: number | null): Promise { if (tenantId != null) { - return database.prepare( + return await database.prepare( "SELECT * FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1", ).get(tenantId) as Company | undefined; } - return database.prepare( + return await database.prepare( "SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1", ).get() as Company | undefined; } @@ -112,26 +113,26 @@ export function companyCodeFromName(name: string): string { return slug || "EMP"; } -export function nextCompanyCode(database: Database, name: string): string { +export async function nextCompanyCode(database: Db, name: string): Promise { const base = companyCodeFromName(name); - if (!companyByCode(database, base)) return base; - const row = database.prepare( - `SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n - FROM companies WHERE code GLOB 'EMP-[0-9][0-9][0-9][0-9]*'`, + if (!await companyByCode(database, base)) return base; + const row = await database.prepare( + `SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n + FROM companies WHERE code ~ '^EMP-[0-9]{4}'`, ).get() as { n: number }; return `EMP-${String(row.n).padStart(4, "0")}`; } -export function resolveCompany( - database: Database, +export async function resolveCompany( + database: Db, body: { company_id?: number | null; hire_type?: string | null }, -): Company | undefined { +): Promise { if (body.company_id) { - const byId = companyById(database, Number(body.company_id)); + const byId = await companyById(database, Number(body.company_id)); if (byId) return byId; } const code = normCompanyCode(body.hire_type); - if (code) return companyByCode(database, code); + if (code) return await companyByCode(database, code); return undefined; } @@ -142,7 +143,10 @@ export function validateCompanyCode(code: string): string | null { return null; } -export function validateCompanyProfile(input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}): string | null { +export function validateCompanyProfile( + input: CompanyProfileInput, + opts: { requireLegal?: boolean } = {}, +): string | null { const rfc = normRfc(input.rfc); if (rfc && !RFC_RE.test(rfc)) { return "RFC inválido (formato mexicano de 12 o 13 caracteres)"; @@ -190,68 +194,79 @@ function profileFromInput(input: CompanyProfileInput, fallbackName = "") { return normalizeCompanyProfile(input, fallbackName); } -export function createSubcompany( - database: Database, +export async function createSubcompany( + database: Db, input: CompanyProfileInput, tenantId?: number | null, -): { company?: Company; error?: string } { +): Promise<{ company?: Company; error?: string }> { const profileErr = validateProfile(input, { requireLegal: true }); if (profileErr) return { error: profileErr }; const profile = profileFromInput(input); if (!profile.name) return { error: "Nombre de empresa obligatorio" }; - const principal = principalCompany(database, tenantId); + const principal = await principalCompany(database, tenantId); if (!principal) return { error: "No hay empresa principal" }; const parentId = input.parent_id ? Number(input.parent_id) : principal.id; - const parent = companyById(database, parentId); + const parent = await companyById(database, parentId); if (!parent) return { error: "Empresa padre no encontrada" }; if (tenantId != null && parent.tenant_id != null && parent.tenant_id !== tenantId) { return { error: "Empresa padre de otro tenant" }; } let code = normCompanyCode(input.code); - if (!code) code = nextCompanyCode(database, profile.name); + if (!code) code = await nextCompanyCode(database, profile.name); const codeErr = validateCompanyCode(code); if (codeErr) return { error: codeErr }; - if (companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" }; + if (await companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" }; const tid = tenantId ?? principal.tenant_id ?? null; - database.prepare( - `INSERT INTO companies ( - code, name, parent_id, kind, status, tenant_id, - registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo, - domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro - ) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - code, - profile.name, - parent.id, - tid, - profile.registro_patronal, - profile.razon_social, - profile.nombre_comercial, - profile.rfc, - profile.regimen_fiscal, - profile.clase_riesgo, - profile.domicilio_fiscal, - profile.codigo_postal, - profile.ciudad, - profile.estado, - profile.telefono, - profile.email, - profile.representante_legal, - profile.giro, - ); - return { company: companyById(database, lastId(database)) }; + try { + await database.prepare( + `INSERT INTO companies ( + code, name, parent_id, kind, status, tenant_id, + registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo, + domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro + ) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + code, + profile.name, + parent.id, + tid, + profile.registro_patronal, + profile.razon_social, + profile.nombre_comercial, + profile.rfc, + profile.regimen_fiscal, + profile.clase_riesgo, + profile.domicilio_fiscal, + profile.codigo_postal, + profile.ciudad, + profile.estado, + profile.telefono, + profile.email, + profile.representante_legal, + profile.giro, + ); + } catch (e) { + if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código" }; + throw e; + } + return { company: await companyById(database, await database.lastInsertId()) }; } -export function updateCompany( - database: Database, +export async function updateCompany( + database: Db, id: number, input: CompanyProfileInput, -): { company?: Company; error?: string; status?: 400 | 404 } { - const current = companyById(database, id); - if (!current) return { error: "Empresa no encontrada", status: 404 }; + tenantId?: number | null, +): Promise<{ company?: Company; error?: string; status?: 400 | 404 }> { + const current = await companyById(database, id); + // Con RLS activo, una fila de otro tenant ya no aparece aquí (la conexión + // solo ve app.tenant_id); este chequeo explícito es defensa adicional y + // un mensaje de error más claro que un 404 "silencioso". + if (!current || (tenantId != null && current.tenant_id != null && current.tenant_id !== tenantId)) { + return { error: "Empresa no encontrada", status: 404 }; + } const profileErr = validateProfile(input); if (profileErr) return { error: profileErr, status: 400 }; @@ -285,7 +300,7 @@ export function updateCompany( code = normCompanyCode(input.code); const codeErr = validateCompanyCode(code); if (codeErr) return { error: codeErr, status: 400 }; - const clash = companyByCode(database, code); + const clash = await companyByCode(database, code); if (clash && clash.id !== id) return { error: "Ya existe una empresa con ese código", status: 400 }; } @@ -300,35 +315,45 @@ export function updateCompany( status = input.status as Company["status"]; } - database.prepare( - `UPDATE companies SET - name = ?, code = ?, status = ?, - registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?, - domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?, - representante_legal = ?, giro = ? - WHERE id = ?`, - ).run( - profile.name, - code, - status, - profile.registro_patronal, - profile.razon_social, - profile.nombre_comercial, - profile.rfc, - profile.regimen_fiscal, - profile.clase_riesgo, - profile.domicilio_fiscal, - profile.codigo_postal, - profile.ciudad, - profile.estado, - profile.telefono, - profile.email, - profile.representante_legal, - profile.giro, - id, - ); - if (code !== current.code) { - database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id); + try { + await database.prepare( + `UPDATE companies SET + name = ?, code = ?, status = ?, + registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?, + domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?, + representante_legal = ?, giro = ? + WHERE id = ?`, + ).run( + profile.name, + code, + status, + profile.registro_patronal, + profile.razon_social, + profile.nombre_comercial, + profile.rfc, + profile.regimen_fiscal, + profile.clase_riesgo, + profile.domicilio_fiscal, + profile.codigo_postal, + profile.ciudad, + profile.estado, + profile.telefono, + profile.email, + profile.representante_legal, + profile.giro, + id, + ); + } catch (e) { + if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código", status: 400 }; + throw e; } - return { company: companyById(database, id) }; + if (code !== current.code) { + await database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id); + } + return { company: await companyById(database, id) }; +} + +/** Postgres error code 23505 = unique_violation. */ +function isUniqueViolation(e: unknown): boolean { + return !!e && typeof e === "object" && (e as { code?: string }).code === "23505"; } diff --git a/api/config.ts b/api/config.ts index 1f21fdd..6f57396 100644 --- a/api/config.ts +++ b/api/config.ts @@ -4,20 +4,39 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); export const ROOT = join(here, ".."); export const DATA_DIR = join(ROOT, "data"); -export const DB_PATH = join(DATA_DIR, "app.db"); -export const PLATFORM_DB_PATH = join(DATA_DIR, "platform.db"); -export const EXPEDIENTES_DIR = join(DATA_DIR, "expedientes"); -export const PDFS_DIR = join(DATA_DIR, "pdfs"); -export const LOGOS_DIR = join(DATA_DIR, "logos"); -export const PROJECTS_DIR = join(DATA_DIR, "proyectos"); -export const COMPANIES_DIR = join(DATA_DIR, "empresas"); +// Cache local de archivos descifrados/temporales (no la fuente de verdad -- +// esa vive en Contabo Object Storage, ver api/storage.ts / Fase 4c). +export const TMP_DIR = join(DATA_DIR, "tmp"); + +function required(name: string): string { + const value = Deno.env.get(name); + if (!value) { + throw new Error( + `Falta la variable de entorno ${name}. No hay default inseguro -- ver .env.example.`, + ); + } + return value; +} + +const isDev = (Deno.env.get("DENO_ENV") ?? "development") !== "production"; + +/** SESSION_SECRET/DOCS_KEY nunca tienen default fuera de desarrollo: un + * default hardcodeado en el código es un secreto público. */ +function requiredOrDevFallback(name: string, devFallback: string): string { + const value = Deno.env.get(name); + if (value) return value; + if (isDev) return devFallback; + throw new Error(`Falta la variable de entorno ${name} (obligatoria fuera de desarrollo).`); +} export const config = { port: Number(Deno.env.get("PORT") ?? "8000"), - sessionSecret: Deno.env.get("SESSION_SECRET") ?? "dev-session-secret-change-me", + sessionSecret: requiredOrDevFallback("SESSION_SECRET", "dev-session-secret-change-me"), apiKey: Deno.env.get("API_KEY") ?? "", - docsKeyHex: Deno.env.get("DOCS_KEY") ?? + docsKeyHex: requiredOrDevFallback( + "DOCS_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), /** Solo vía env. Compose exige SEED_PASSWORD; no hay default en código. */ seedPassword: Deno.env.get("SEED_PASSWORD") ?? "", vcardBase: Deno.env.get("VCARD_BASE") ?? "https://vcard.arctec.com.mx?info=", @@ -32,4 +51,38 @@ export const config = { cookieSecure: ["1", "true", "yes"].includes( (Deno.env.get("COOKIE_SECURE") ?? "").toLowerCase(), ), + + // --- Postgres: panels_platform (control plane SaaS, base separada) --- + databaseUrlPlatform: isDev + ? (Deno.env.get("DATABASE_URL_PLATFORM") ?? "") + : required("DATABASE_URL_PLATFORM"), + // --- Postgres: panels_product, esquema iam (identidad del tenant) --- + databaseUrlIam: isDev + ? (Deno.env.get("DATABASE_URL_IAM") ?? "") + : required("DATABASE_URL_IAM"), + // Credenciales _owner: SOLO para resolver el login por username (ver + // api/iam_db.ts#getAuthPool) -- el dueño de la tabla no está sujeto a + // Row Level Security, necesario porque el username es único globalmente + // y hay que ubicarlo antes de conocer su tenant_id. + databaseUrlIamOwner: Deno.env.get("DATABASE_URL_IAM_OWNER") ?? "", + // --- Postgres: panels_product, esquema core (negocio) --- + databaseUrlCore: isDev + ? (Deno.env.get("DATABASE_URL_CORE") ?? "") + : required("DATABASE_URL_CORE"), + // Credenciales _owner: solo para scripts (bootstrap/ETL) y resoluciones + // internas puntuales que cruzan tenants a propósito (ver api/db.ts#getCoreDb). + databaseUrlCoreOwner: Deno.env.get("DATABASE_URL_CORE_OWNER") ?? "", + + // --- Redis: sesiones (iam:*) y cache (core:*) --- + redisUrlIam: isDev ? (Deno.env.get("REDIS_URL_IAM") ?? "") : required("REDIS_URL_IAM"), + redisUrlCore: isDev ? (Deno.env.get("REDIS_URL_CORE") ?? "") : required("REDIS_URL_CORE"), + + // --- Contabo Object Storage (S3-compatible) -- expedientes/PDFs/logos --- + s3Endpoint: Deno.env.get("S3_ENDPOINT") ?? "", + s3Bucket: Deno.env.get("S3_BUCKET") ?? "", + s3Region: Deno.env.get("S3_REGION") ?? "us-east-1", + s3AccessKeyId: Deno.env.get("S3_ACCESS_KEY_ID") ?? "", + s3SecretAccessKey: Deno.env.get("S3_SECRET_ACCESS_KEY") ?? "", + + isDev, }; diff --git a/api/crypto.ts b/api/crypto.ts index 259dec4..ed5f743 100644 --- a/api/crypto.ts +++ b/api/crypto.ts @@ -25,7 +25,7 @@ export async function hashPassword(password: string): Promise { ["deriveBits"], ); const bits = await crypto.subtle.deriveBits( - { name: "PBKDF2", salt, iterations: 120_000, hash: "SHA-256" }, + { name: "PBKDF2", salt: salt as BufferSource, iterations: 120_000, hash: "SHA-256" }, key, 256, ); @@ -55,7 +55,7 @@ export async function verifyPassword(password: string, stored: string): Promise< ); const bits = new Uint8Array( await crypto.subtle.deriveBits( - { name: "PBKDF2", salt, iterations: 120_000, hash: "SHA-256" }, + { name: "PBKDF2", salt: salt as BufferSource, iterations: 120_000, hash: "SHA-256" }, key, 256, ), @@ -66,44 +66,8 @@ export async function verifyPassword(password: string, stored: string): Promise< return diff === 0; } -export async function hmacSign(secret: string, payload: string): Promise { - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); - return btoa(String.fromCharCode(...new Uint8Array(sig))) - .replaceAll("+", "-") - .replaceAll("/", "_") - .replaceAll("=", ""); -} - -export async function hmacVerify(secret: string, payload: string, sig: string): Promise { - const expected = await hmacSign(secret, payload); - if (expected.length !== sig.length) return false; - let diff = 0; - for (let i = 0; i < expected.length; i++) { - diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i); - } - return diff === 0; -} - -export function b64urlJson(obj: unknown): string { - const json = JSON.stringify(obj); - return btoa(json).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); -} - -export function b64urlJsonParse(s: string): T { - const pad = s.replaceAll("-", "+").replaceAll("_", "/"); - const padded = pad + "=".repeat((4 - (pad.length % 4)) % 4); - return JSON.parse(atob(padded)) as T; -} - export async function sha256Hex(data: Uint8Array): Promise { - return toHex(await crypto.subtle.digest("SHA-256", data)); + return toHex(await crypto.subtle.digest("SHA-256", data as BufferSource)); } export { toHex, fromHex, encoder, decoder }; diff --git a/api/db.ts b/api/db.ts index 0794527..51cf83a 100644 --- a/api/db.ts +++ b/api/db.ts @@ -1,93 +1,64 @@ -import { Database } from "@db/sqlite"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; -import { DATA_DIR, DB_PATH, EXPEDIENTES_DIR, PDFS_DIR, LOGOS_DIR, PROJECTS_DIR, COMPANIES_DIR } from "./config.ts"; +import postgres, { createPool, PgDb, withTenant } from "./pg.ts"; import { config } from "./config.ts"; -import { hashPassword } from "./crypto.ts"; -import { DOCUMENT_TYPE_SEED, PROJECT_DOCUMENT_TYPE_SEED, COMPANY_DOCUMENT_TYPE_SEED, evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts"; -import { runLiquibase } from "./liquibase.ts"; +import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts"; -export type Db = Database; +export type Db = PgDb; -let db: Database | null = null; +/** + * panels_product, esquema core: negocio (empresas, personal, obras, + * expedientes, presupuesto, nómina, gafetes). Aislado de `iam` a propósito + * -- este módulo no debe importar iam_db.ts. + */ -export async function getDb(): Promise { - if (db) return db; - await mkdir(DATA_DIR, { recursive: true }); - await mkdir(EXPEDIENTES_DIR, { recursive: true }); - await mkdir(PDFS_DIR, { recursive: true }); - await mkdir(LOGOS_DIR, { recursive: true }); - await mkdir(PROJECTS_DIR, { recursive: true }); - await mkdir(COMPANIES_DIR, { recursive: true }); - await runLiquibase(); - db = new Database(DB_PATH); - db.exec("PRAGMA foreign_keys = ON;"); - await seed(db); - return db; -} +let corePool: postgres.Sql | null = null; +let coreOwnerPool: postgres.Sql | null = null; -export function seedDocumentTypes(database: Database) { - const upsert = database.prepare( - `INSERT INTO document_types - (code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(code) DO UPDATE SET - label = excluded.label, - required = excluded.required, - validity_mode = excluded.validity_mode, - freshness_days = excluded.freshness_days, - requires_issued_at = excluded.requires_issued_at, - requires_expires_at = excluded.requires_expires_at, - category = excluded.category`, - ); - for (const d of DOCUMENT_TYPE_SEED) { - upsert.run( - d.code, - d.label, - d.required, - d.validity_mode, - d.freshness_days, - d.requires_issued_at, - d.requires_expires_at, - d.category, - ); +function getCorePool(): postgres.Sql { + if (!corePool) { + corePool = createPool(config.databaseUrlCore, { max: 20 }); } + return corePool; } -export function seedProjectDocumentTypes(database: Database) { - const upsert = database.prepare( - `INSERT INTO project_document_types (code, label, required, category) - VALUES (?, ?, ?, ?) - ON CONFLICT(code) DO UPDATE SET - label = excluded.label, - required = excluded.required, - category = excluded.category`, - ); - for (const d of PROJECT_DOCUMENT_TYPE_SEED) { - upsert.run(d.code, d.label, d.required, d.category); +function getCoreOwnerPool(): postgres.Sql { + if (!coreOwnerPool) { + coreOwnerPool = createPool(config.databaseUrlCoreOwner || config.databaseUrlCore, { max: 3 }); } + return coreOwnerPool; } -export function seedCompanyDocumentTypes(database: Database) { - const upsert = database.prepare( - `INSERT INTO company_document_types (code, label, required, category) - VALUES (?, ?, ?, ?) - ON CONFLICT(code) DO UPDATE SET - label = excluded.label, - required = excluded.required, - category = excluded.category`, - ); - for (const d of COMPANY_DOCUMENT_TYPE_SEED) { - upsert.run(d.code, d.label, d.required, d.category); - } +export async function pingCoreDb(): Promise { + await getCorePool()`SELECT 1`; } -export function nextProjectCode(database: Database): string { - const row = database.prepare( - `SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n - FROM projects WHERE code GLOB 'PRY-[0-9][0-9][0-9][0-9]*' OR code GLOB 'OBR-[0-9][0-9][0-9][0-9]*'`, - ).get() as { n: number }; - return `PRY-${String(row.n).padStart(4, "0")}`; +/** El único camino "normal" para atender un request: abre una transacción + * con app.tenant_id fijado (Row Level Security), y la cierra sola al + * terminar `fn` (commit) o al lanzar (rollback). Ver api/pg.ts#withTenant + * y las políticas en db/core/changesets/005-rls.sql. */ +export async function withCoreTenant( + tenantId: number | null, + fn: (db: Db) => Promise, +): Promise { + return await withTenant(getCorePool(), tenantId, fn); +} + +/** Conexión SIN RLS (rol _owner, dueño de las tablas) -- reservada para + * scripts (bootstrap, ETL) y para resoluciones internas puntuales que + * necesitan cruzar tenants a propósito (ej. auth.ts al enriquecer el login + * con el nombre de la empresa). NO usar en handlers de request normales. */ +export async function getCoreDb(): Promise { + return new PgDb(getCoreOwnerPool()); +} + +export async function closeCoreDb(): Promise { + await corePool?.end({ timeout: 5 }); + await coreOwnerPool?.end({ timeout: 5 }); + corePool = null; + coreOwnerPool = null; +} + +export async function lastInsertId(database: Db): Promise { + return await database.lastInsertId(); } export const PROJECT_STATUSES = ["activo", "pausado", "concluido", "cancelado"] as const; @@ -104,8 +75,16 @@ export function isProjectStatus(value: string): value is ProjectStatus { return (PROJECT_STATUSES as readonly string[]).includes(value); } -export function projectById(database: Database, id: number) { - return database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as +export async function nextProjectCode(database: Db): Promise { + const row = await database.prepare( + `SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n + FROM projects WHERE code ~ '^PRY-[0-9]{4}' OR code ~ '^OBR-[0-9]{4}'`, + ).get() as { n: number }; + return `PRY-${String(row.n).padStart(4, "0")}`; +} + +export async function projectById(database: Db, id: number) { + return await database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as | { id: number; code: string; name: string; status: string } | undefined; } @@ -122,103 +101,17 @@ export function projectMustBe( return null; } -async function seed(database: Database) { - const risks = [ - ["rojo", "Rojo", "#A20000", "#FFFFFF"], - ["amarillo", "Amarillo", "#EEEE3C", "#000000"], - ["azul", "Azul", "#001485", "#FFFFFF"], - ["verde", "Verde", "#008514", "#FFFFFF"], - ["negro", "Negro", "#000000", "#FFFFFF"], - ["naranja", "Naranja", "#FF5733", "#FFFFFF"], - ]; - const insRisk = database.prepare( - "INSERT OR IGNORE INTO risk_levels (code, label, color, text_color) VALUES (?, ?, ?, ?)", - ); - for (const r of risks) insRisk.run(...r); - - database.prepare( - "INSERT OR IGNORE INTO badge_themes (id, name, layout) VALUES (?, ?, ?)", - ).run( - "arctec-dos-logos-fold", - "Arctec dos logos (doblez carta)", - "letter-landscape-4-fold", - ); - - seedDocumentTypes(database); - seedProjectDocumentTypes(database); - seedCompanyDocumentTypes(database); - - const defaultTenantId = 1; - let principal = database.prepare( - "SELECT id FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1", - ).get(defaultTenantId) as { id: number } | undefined; - if (!principal) { - database.prepare( - `INSERT INTO companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social) - VALUES ('ARCT2608', 'ARCTEC', NULL, 'principal', ?, 'ARCTEC', 'ARCTEC')`, - ).run(defaultTenantId); - principal = database.prepare( - "SELECT id FROM companies WHERE code = 'ARCT2608'", - ).get() as { id: number }; - } - for (const [code, name] of [["FISICA", "FISICA"], ["ARCOTEC", "ARCOTEC"]] as const) { - const exists = database.prepare("SELECT id FROM companies WHERE code = ?").get(code); - if (!exists) { - database.prepare( - `INSERT INTO companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social) - VALUES (?, ?, ?, 'sub', ?, ?, ?)`, - ).run(code, name, principal.id, defaultTenantId, name, name); - } - } - - const users = [ - ["arct2608", "Administrador"], - ]; - // Migración suave de usuarios legacy → código puro - database.prepare( - "UPDATE users SET username = 'arct2608' WHERE username IN ('papa', 'adm.arctec') AND NOT EXISTS (SELECT 1 FROM users WHERE username = 'arct2608')", - ).run(); - for (const [username, display] of users) { - const exists = database.prepare("SELECT id FROM users WHERE username = ?").get(username); - if (!exists) { - const hash = await hashPassword(config.seedPassword); - database.prepare( - `INSERT INTO users (username, password_hash, display_name, company_id, tenant_id, role) - VALUES (?, ?, ?, ?, ?, 'tenant_admin')`, - ).run(username, hash, display, principal.id, defaultTenantId); - } - } - database.prepare( - "UPDATE users SET company_id = ?, tenant_id = COALESCE(tenant_id, ?), role = COALESCE(NULLIF(role, ''), 'tenant_admin') WHERE company_id IS NULL OR tenant_id IS NULL", - ).run(principal.id, defaultTenantId); - - const proj = database.prepare("SELECT id FROM projects LIMIT 1").get(); - if (!proj) { - database.prepare( - `INSERT INTO projects (code, name, address, theme_id, company_id, tenant_id) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run( - nextProjectCode(database), - "ZENDALA CANCUN", - "", - "arctec-dos-logos-fold", - principal.id, - defaultTenantId, - ); - } -} - -export function workerImssContext(database: Database, workerId: number): WorkerImssContext { - const w = database.prepare( +export async function workerImssContext(database: Db, workerId: number): Promise { + const w = await database.prepare( "SELECT imss_status, imss_alta_at, last_rehire_at FROM workers WHERE id = ?", ).get(workerId) as { imss_status: ImssStatus; imss_alta_at: string | null; last_rehire_at: string | null; } | undefined; - const altaDoc = database.prepare( + const altaDoc = await database.prepare( `SELECT imss_alta_at, uploaded_at FROM documents - WHERE worker_id = ? AND type_code = 'alta_imss' AND is_current = 1 + WHERE worker_id = ? AND type_code = 'alta_imss' AND is_current = true LIMIT 1`, ).get(workerId) as { imss_alta_at: string | null; uploaded_at: string } | undefined; return { @@ -228,25 +121,30 @@ export function workerImssContext(database: Database, workerId: number): WorkerI }; } -/** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor. */ -export function checklistItemsFor(database: Database, workerId: number) { - const types = database.prepare( +/** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor. + * tenantId (Fase 4d) resuelve la zona horaria del tenant para calcular + * "hoy" de forma consistente con nómina -- antes esta función usaba + * `new Date()` crudo (hora del servidor/UTC) sin relación con PAYROLL_TZ. */ +export async function checklistItemsFor(database: Db, workerId: number, tenantId: number | null = null) { + const tz = await tenantTimezone(database, tenantId); + const today = todayInTimezone(tz); + const types = await database.prepare( `SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category FROM document_types`, ).all() as { code: string; label: string; - required: number; + required: boolean; validity_mode: string; freshness_days: number | null; - requires_issued_at: number; - requires_expires_at: number; + requires_issued_at: boolean; + requires_expires_at: boolean; category: string; }[]; - const currentDocs = database.prepare( + const currentDocs = await database.prepare( `SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at - FROM documents WHERE worker_id = ? AND is_current = 1`, + FROM documents WHERE worker_id = ? AND is_current = true`, ).all(workerId) as { type_code: string; issued_at: string | null; @@ -255,7 +153,7 @@ export function checklistItemsFor(database: Database, workerId: number) { imss_alta_at: string | null; }[]; const byType = new Map(currentDocs.map((d) => [d.type_code, d])); - const ctx = workerImssContext(database, workerId); + const ctx = await workerImssContext(database, workerId); return { ctx, @@ -270,7 +168,7 @@ export function checklistItemsFor(database: Database, workerId: number) { requires_issued_at: !!t.requires_issued_at, requires_expires_at: !!t.requires_expires_at, }; - const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx); + const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx, today); return { code: t.code, label: t.label, @@ -290,11 +188,11 @@ export function checklistItemsFor(database: Database, workerId: number) { }; } -export function imssFlagsFor(database: Database, workerId: number) { - const { ctx, freshness_required: needFresh, items } = checklistItemsFor(database, workerId); +export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) { + const { ctx, freshness_required: needFresh, items } = await checklistItemsFor(database, workerId, tenantId); const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid); - const inProject = !!database.prepare( - "SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1", + const inProject = !!await database.prepare( + "SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1", ).get(workerId); const hasImss = ctx.imss_status === "alta"; return { @@ -306,62 +204,45 @@ export function imssFlagsFor(database: Database, workerId: number) { }; } -export function checklistFor(database: Database, workerId: number) { - return checklistItemsFor(database, workerId).items; +export async function checklistFor(database: Db, workerId: number, tenantId: number | null = null) { + return (await checklistItemsFor(database, workerId, tenantId)).items; } -export function refreshPipeline(database: Database, workerId: number) { - const w = database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as +export async function refreshPipeline(database: Db, workerId: number, tenantId: number | null = null): Promise { + const w = await database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as | { status: string } | undefined; if (!w) return; if (w.status === "baja") { - database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId); + await database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId); return; } - const items = checklistFor(database, workerId); + const items = await checklistFor(database, workerId, tenantId); const photo = items.find((i) => i.code === "foto"); const photoOk = photo?.present && photo?.valid; const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid); - const printed = database.prepare( + const printed = await database.prepare( `SELECT 1 FROM badge_job_people p JOIN badge_jobs j ON j.id = p.job_id WHERE p.worker_id = ? LIMIT 1`, ).get(workerId); let pipeline = "incompleto"; if (requiredOk && photoOk) pipeline = printed ? "impreso" : "listo_gafete"; - const assigned = database.prepare( - "SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1", + const assigned = await database.prepare( + "SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1", ).get(workerId); if (pipeline !== "incompleto" && assigned) { pipeline = printed ? "activo" : "listo_gafete"; } - database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId); + await database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId); } -export function lastInsertId(database: Database): number { - const row = database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }; - return Number(row.id); -} - -export function workerDir(workerId: number): string { - return join(EXPEDIENTES_DIR, String(workerId)); -} - -export function projectDir(projectId: number): string { - return join(PROJECTS_DIR, String(projectId)); -} - -export function companyDir(companyId: number): string { - return join(COMPANIES_DIR, String(companyId)); -} - -export function projectChecklistFor(database: Database, projectId: number) { - const types = database.prepare( +export async function projectChecklistFor(database: Db, projectId: number) { + const types = await database.prepare( "SELECT code, label, required, category FROM project_document_types", - ).all() as { code: string; label: string; required: number; category: string }[]; - const current = database.prepare( - `SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = 1`, + ).all() as { code: string; label: string; required: boolean; category: string }[]; + const current = await database.prepare( + `SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = true`, ).all(projectId) as { type_code: string }[]; const have = new Set(current.map((c) => c.type_code)); return types.map((t) => ({ @@ -373,12 +254,12 @@ export function projectChecklistFor(database: Database, projectId: number) { })); } -export function companyChecklistFor(database: Database, companyId: number) { - const types = database.prepare( +export async function companyChecklistFor(database: Db, companyId: number) { + const types = await database.prepare( "SELECT code, label, required, category FROM company_document_types", - ).all() as { code: string; label: string; required: number; category: string }[]; - const current = database.prepare( - `SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = 1`, + ).all() as { code: string; label: string; required: boolean; category: string }[]; + const current = await database.prepare( + `SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = true`, ).all(companyId) as { type_code: string }[]; const have = new Set(current.map((c) => c.type_code)); return types.map((t) => ({ @@ -389,3 +270,21 @@ export function companyChecklistFor(database: Database, companyId: number) { present: have.has(t.code), })); } + +/** Zona horaria del tenant (Fase 4d) -- reemplaza el PAYROLL_TZ + * hardcodeado. Con default sensato si el tenant no la configuró. */ +export async function tenantTimezone(database: Db, tenantId: number | null): Promise { + if (tenantId == null) return "America/Mexico_City"; + const row = await database.prepare( + "SELECT timezone FROM tenant_settings WHERE tenant_id = ?", + ).get(tenantId) as { timezone: string } | undefined; + return row?.timezone || "America/Mexico_City"; +} + +/** "Hoy" en la zona horaria del tenant, formato ISO (YYYY-MM-DD). Usado por + * nómina y por validación de vigencia de documentos -- antes eran dos + * nociones de "hoy" distintas (PAYROLL_TZ vs. new Date() crudo); ahora es + * una sola función parametrizada por tenant. */ +export function todayInTimezone(timezone: string, now = new Date()): string { + return now.toLocaleDateString("en-CA", { timeZone: timezone }); +} diff --git a/api/deno.json b/api/deno.json index 6653b44..0dd5e86 100644 --- a/api/deno.json +++ b/api/deno.json @@ -2,14 +2,13 @@ "name": "panel-obra-api", "exports": "./main.ts", "tasks": { - "dev": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --allow-run --env-file=../.env main.ts", - "start": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --allow-run --env-file=../.env main.ts", + "dev": "deno run --allow-net --allow-read --allow-write --allow-env --env-file=../.env main.ts", + "start": "deno run --allow-net --allow-read --allow-write --allow-env main.ts", "migrate": "cd .. && ./db/update.sh all", "check": "deno check main.ts", - "test": "deno test --allow-read --allow-write --allow-env --allow-ffi --allow-run" + "test": "deno test --allow-net --allow-read --allow-write --allow-env" }, "imports": { - "@db/sqlite": "jsr:@db/sqlite@0.12", "hono": "jsr:@hono/hono@4", "xlsx": "npm:xlsx@0.18.5", "pdf-lib": "npm:pdf-lib@1.17.1", diff --git a/api/deno.lock b/api/deno.lock index 8c445f0..fef68da 100644 --- a/api/deno.lock +++ b/api/deno.lock @@ -1,84 +1,233 @@ { "version": "5", "specifiers": { - "jsr:@db/sqlite@0.12": "0.12.0", - "jsr:@denosaurs/plug@1": "1.1.0", "jsr:@hono/hono@4": "4.13.2", - "jsr:@std/assert@0.217": "0.217.0", "jsr:@std/assert@1": "1.0.19", - "jsr:@std/encoding@1": "1.0.11", - "jsr:@std/fmt@1": "1.0.10", - "jsr:@std/fs@1": "1.0.24", "jsr:@std/internal@^1.0.12": "1.0.14", - "jsr:@std/internal@^1.0.14": "1.0.14", - "jsr:@std/path@0.217": "0.217.0", - "jsr:@std/path@1": "1.1.6", - "jsr:@std/path@^1.1.5": "1.1.6", + "npm:@aws-sdk/client-s3@3": "3.1124.0", "npm:@types/node@*": "22.15.15", "npm:nodemailer@6.9.16": "6.9.16", "npm:pdf-lib@1.17.1": "1.17.1", + "npm:postgres@3": "3.4.9", "npm:qrcode@1.5.4": "1.5.4", + "npm:redis@4": "4.7.1", "npm:xlsx@0.18.5": "0.18.5" }, "jsr": { - "@db/sqlite@0.12.0": { - "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", - "dependencies": [ - "jsr:@denosaurs/plug", - "jsr:@std/path@0.217" - ] - }, - "@denosaurs/plug@1.1.0": { - "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", - "dependencies": [ - "jsr:@std/encoding", - "jsr:@std/fmt", - "jsr:@std/fs", - "jsr:@std/path@1" - ] - }, "@hono/hono@4.13.2": { "integrity": "715d8cc1b6b5d6b9e6a7519059778d775b2d79fffdc026ac0ccdd9971e75809c" }, - "@std/assert@0.217.0": { - "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" - }, "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", "dependencies": [ - "jsr:@std/internal@^1.0.12" - ] - }, - "@std/encoding@1.0.11": { - "integrity": "e7cef2f0b3153bccc17431e7c864a1de03a4b6d9647389c43e08aaa775d37385" - }, - "@std/fmt@1.0.10": { - "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" - }, - "@std/fs@1.0.24": { - "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", - "dependencies": [ - "jsr:@std/internal@^1.0.14", - "jsr:@std/path@^1.1.5" + "jsr:@std/internal" ] }, "@std/internal@1.0.14": { "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" - }, - "@std/path@0.217.0": { - "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", - "dependencies": [ - "jsr:@std/assert@0.217" - ] - }, - "@std/path@1.1.6": { - "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", - "dependencies": [ - "jsr:@std/internal@^1.0.14" - ] } }, "npm": { + "@aws-sdk/checksums@3.1000.29": { + "integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/client-s3@3.1124.0": { + "integrity": "sha512-f20BksgVlXufcf/mijde1LAjwM/iSDdG3mGtN3yRFOUzXtlFEOjxNi1Rf2Kb+rSgoiOSpO0hUOna9Bs+J1gX2A==", + "dependencies": [ + "@aws-sdk/checksums", + "@aws-sdk/core", + "@aws-sdk/credential-provider-node", + "@aws-sdk/middleware-sdk-s3", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/core@3.977.9": { + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "dependencies": [ + "@aws-sdk/types", + "@aws-sdk/xml-builder", + "@aws/lambda-invoke-store", + "@smithy/core", + "@smithy/signature-v4", + "@smithy/types", + "bowser", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-env@3.972.70": { + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-http@3.972.72": { + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-ini@3.973.15": { + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/credential-provider-env", + "@aws-sdk/credential-provider-http", + "@aws-sdk/credential-provider-login", + "@aws-sdk/credential-provider-process", + "@aws-sdk/credential-provider-sso", + "@aws-sdk/credential-provider-web-identity", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/credential-provider-imds", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-login@3.972.77": { + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-node@3.972.82": { + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "dependencies": [ + "@aws-sdk/credential-provider-env", + "@aws-sdk/credential-provider-http", + "@aws-sdk/credential-provider-ini", + "@aws-sdk/credential-provider-process", + "@aws-sdk/credential-provider-sso", + "@aws-sdk/credential-provider-web-identity", + "@aws-sdk/types", + "@smithy/core", + "@smithy/credential-provider-imds", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-process@3.972.70": { + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-sso@3.973.14": { + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/token-providers", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/credential-provider-web-identity@3.972.76": { + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/middleware-sdk-s3@3.972.75": { + "integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/nested-clients@3.997.44": { + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/signature-v4-multi-region@3.996.46": { + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "dependencies": [ + "@aws-sdk/types", + "@smithy/signature-v4", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/token-providers@3.1116.0": { + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/types@3.974.5": { + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "dependencies": [ + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws-sdk/xml-builder@3.972.40": { + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "dependencies": [ + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@aws/lambda-invoke-store@0.3.0": { + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==" + }, "@pdf-lib/standard-fonts@1.0.0": { "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", "dependencies": [ @@ -91,6 +240,89 @@ "pako" ] }, + "@redis/bloom@1.2.0_@redis+client@1.6.1": { + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/client@1.6.1": { + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "dependencies": [ + "cluster-key-slot", + "generic-pool", + "yallist" + ] + }, + "@redis/graph@1.1.1_@redis+client@1.6.1": { + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/json@1.0.7_@redis+client@1.6.1": { + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/search@1.2.0_@redis+client@1.6.1": { + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/time-series@1.1.0_@redis+client@1.6.1": { + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "dependencies": [ + "@redis/client" + ] + }, + "@smithy/core@3.33.3": { + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "dependencies": [ + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@smithy/credential-provider-imds@4.5.2": { + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@smithy/fetch-http-handler@5.7.2": { + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@smithy/node-http-handler@4.12.0": { + "integrity": "sha512-0mq1pHadfyXCYCqm2cNpbjNIT+fbaUpNxewZb/YNr2L0IrEVMOb8gM/Fl4K6XvHCW3uSNDFwPl/+iKm0bx9jYg==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@smithy/signature-v4@5.7.3": { + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib@2.8.1" + ] + }, + "@smithy/types@4.17.2": { + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "dependencies": [ + "tslib@2.8.1" + ] + }, "@types/node@22.15.15": { "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", "dependencies": [ @@ -109,6 +341,9 @@ "color-convert" ] }, + "bowser@2.14.1": { + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" + }, "camelcase@5.3.1": { "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" }, @@ -127,6 +362,9 @@ "wrap-ansi" ] }, + "cluster-key-slot@1.1.2": { + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" + }, "codepage@1.15.0": { "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==" }, @@ -162,6 +400,9 @@ "frac@1.1.2": { "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==" }, + "generic-pool@3.9.0": { + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==" + }, "get-caller-file@2.0.5": { "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, @@ -204,12 +445,15 @@ "@pdf-lib/standard-fonts", "@pdf-lib/upng", "pako", - "tslib" + "tslib@1.14.1" ] }, "pngjs@5.0.0": { "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==" }, + "postgres@3.4.9": { + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==" + }, "qrcode@1.5.4": { "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", "dependencies": [ @@ -219,6 +463,17 @@ ], "bin": true }, + "redis@4.7.1": { + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "dependencies": [ + "@redis/bloom", + "@redis/client", + "@redis/graph", + "@redis/json", + "@redis/search", + "@redis/time-series" + ] + }, "require-directory@2.1.1": { "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" }, @@ -251,6 +506,9 @@ "tslib@1.14.1": { "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "undici-types@6.21.0": { "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, @@ -287,6 +545,9 @@ "y18n@4.0.3": { "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" }, + "yallist@4.0.0": { + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "yargs-parser@18.1.3": { "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "dependencies": [ @@ -348,7 +609,6 @@ }, "workspace": { "dependencies": [ - "jsr:@db/sqlite@0.12", "jsr:@hono/hono@4", "npm:pdf-lib@1.17.1", "npm:qrcode@1.5.4", diff --git a/api/docs_crypto.ts b/api/docs_crypto.ts index 349549c..e4a1e28 100644 --- a/api/docs_crypto.ts +++ b/api/docs_crypto.ts @@ -6,14 +6,14 @@ async function docsKey(): Promise { if (raw.length !== 32) { throw new Error("DOCS_KEY must be 64 hex chars (32 bytes)"); } - return await crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]); + return await crypto.subtle.importKey("raw", raw as BufferSource, "AES-GCM", false, ["encrypt", "decrypt"]); } export async function encryptBytes(plain: Uint8Array): Promise<{ iv: string; cipher: Uint8Array }> { const key = await docsKey(); const iv = crypto.getRandomValues(new Uint8Array(12)); const cipher = new Uint8Array( - await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plain), + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plain as BufferSource), ); return { iv: toHex(iv), cipher }; } @@ -22,6 +22,6 @@ export async function decryptBytes(ivHex: string, cipher: Uint8Array): Promise days) { + const todayForFreshness = parseDay(today) ?? new Date(); + if (issued && daysBetween(issued, todayForFreshness) > days) { return { present: true, valid: false, diff --git a/api/document_validity_test.ts b/api/document_validity_test.ts index 1010730..216bcff 100644 --- a/api/document_validity_test.ts +++ b/api/document_validity_test.ts @@ -79,7 +79,7 @@ Deno.test("CURP stale when freshness required", () => { curpPolicy, { type_code: "curp", issued_at: "2025-01-01", expires_at: null, uploaded_at: "2025-01-02" }, ctx, - new Date("2026-08-20T12:00:00"), + "2026-08-20", ); assertEquals(result.validity_status, "stale_for_alta"); assertEquals(result.valid, false); @@ -95,7 +95,7 @@ Deno.test("CURP old but ok when already alta", () => { curpPolicy, { type_code: "curp", issued_at: "2025-01-01", expires_at: null, uploaded_at: "2025-01-02" }, ctx, - new Date("2026-08-20T12:00:00"), + "2026-08-20", ); assertEquals(result.validity_status, "ok"); assertEquals(result.valid, true); @@ -111,7 +111,7 @@ Deno.test("INE expired always invalid", () => { inePolicy, { type_code: "ine", issued_at: null, expires_at: "2025-12-31", uploaded_at: "2025-01-01" }, ctx, - new Date("2026-08-20T12:00:00"), + "2026-08-20", ); assertEquals(result.validity_status, "expired"); assertEquals(result.valid, false); diff --git a/api/excel.ts b/api/excel.ts index 1dbec39..a2639ed 100644 --- a/api/excel.ts +++ b/api/excel.ts @@ -1,12 +1,11 @@ import * as XLSX from "xlsx"; -import type { Database } from "@db/sqlite"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; +import type { Db } from "./db.ts"; import { encryptBytes } from "./docs_crypto.ts"; import { sha256Hex } from "./crypto.ts"; import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts"; -import { refreshPipeline, workerDir, lastInsertId, projectDir, companyDir } from "./db.ts"; +import { refreshPipeline, lastInsertId } from "./db.ts"; import { resolveCompany } from "./companies.ts"; +import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts"; export const IMPORT_COLUMNS = [ "NOMBRE", @@ -190,8 +189,8 @@ export function buildImportTemplate(companies: { code: string; name: string }[] return out instanceof Uint8Array ? out : new Uint8Array(out); } -function findExisting(db: Database, curp: string, rfc: string, nss: string) { - return db.prepare( +async function findExisting(db: Db, curp: string, rfc: string, nss: string) { + return await db.prepare( `SELECT id, first_name, last_name_p, curp, rfc, nss FROM workers WHERE curp = ? OR rfc = ? OR nss = ? LIMIT 1`, ).get(curp, rfc, nss) as @@ -200,7 +199,7 @@ function findExisting(db: Database, curp: string, rfc: string, nss: string) { } export async function storeDocument( - db: Database, + db: Db, workerId: number, type: string, filename: string, @@ -215,16 +214,14 @@ export async function storeDocument( imss_baja_at?: string | null; } = {}, ) { - const allowed = db.prepare("SELECT code FROM document_types WHERE code = ?").get(type); + const allowed = await db.prepare("SELECT code FROM document_types WHERE code = ?").get(type); if (!allowed) throw new Error("Tipo de documento no válido"); const { iv, cipher } = await encryptBytes(bytes); const hash = await sha256Hex(bytes); const storage = `${crypto.randomUUID()}.enc`; - const dir = workerDir(workerId); - await mkdir(dir, { recursive: true }); - await Deno.writeFile(join(dir, storage), cipher); - db.prepare("UPDATE documents SET is_current = 0 WHERE worker_id = ? AND type_code = ?").run( + await putObject(workerDocKey(workerId, storage), cipher); + await db.prepare("UPDATE documents SET is_current = false WHERE worker_id = ? AND type_code = ?").run( workerId, type, ); @@ -236,11 +233,11 @@ export async function storeDocument( const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null); const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt; - db.prepare( + await db.prepare( `INSERT INTO documents (worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, - parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?, ?, ?, ?, ?)`, + parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by_id, uploaded_by_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, ?, ?, ?, ?, '')`, ).run( workerId, type, @@ -259,16 +256,16 @@ export async function storeDocument( if (type === "alta_imss") { const companyId = imssCompanyId || - (db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined) + (await db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined) ?.company_id || null; const company = companyId - ? db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as + ? await db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as | { id: number; code: string } | undefined : undefined; if (!company) throw new Error("Empresa patrón no válida"); - db.prepare( + await db.prepare( `UPDATE workers SET imss_status = 'alta', imss_company_id = ?, @@ -276,29 +273,29 @@ export async function storeDocument( imss_baja_at = NULL, company_id = ?, hire_type = ?, - updated_at = datetime('now') + updated_at = now() WHERE id = ?`, ).run(company.id, imssAltaAt, company.id, company.code, workerId); } if (type === "baja_imss") { - db.prepare( + await db.prepare( `UPDATE workers SET imss_status = 'baja_imss', imss_baja_at = ?, imss_company_id = NULL, company_id = NULL, hire_type = '', - updated_at = datetime('now') + updated_at = now() WHERE id = ?`, ).run(imssBajaAt, workerId); } - refreshPipeline(db, workerId); + await refreshPipeline(db, workerId); } export async function storeProjectDocument( - db: Database, + db: Db, projectId: number, type: string, filename: string, @@ -306,27 +303,25 @@ export async function storeProjectDocument( bytes: Uint8Array, userId: number | null, ) { - const allowed = db.prepare("SELECT code FROM project_document_types WHERE code = ?").get(type); + const allowed = await db.prepare("SELECT code FROM project_document_types WHERE code = ?").get(type); if (!allowed) throw new Error("Tipo de documento no válido"); const { iv, cipher } = await encryptBytes(bytes); const hash = await sha256Hex(bytes); const storage = `${crypto.randomUUID()}.enc`; - const dir = projectDir(projectId); - await mkdir(dir, { recursive: true }); - await Deno.writeFile(join(dir, storage), cipher); - db.prepare("UPDATE project_documents SET is_current = 0 WHERE project_id = ? AND type_code = ?").run( + await putObject(projectDocKey(projectId, storage), cipher); + await db.prepare("UPDATE project_documents SET is_current = false WHERE project_id = ? AND type_code = ?").run( projectId, type, ); - db.prepare( + await db.prepare( `INSERT INTO project_documents - (project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?)`, + (project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`, ).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); } export async function storeCompanyDocument( - db: Database, + db: Db, companyId: number, type: string, filename: string, @@ -334,22 +329,20 @@ export async function storeCompanyDocument( bytes: Uint8Array, userId: number | null, ) { - const allowed = db.prepare("SELECT code FROM company_document_types WHERE code = ?").get(type); + const allowed = await db.prepare("SELECT code FROM company_document_types WHERE code = ?").get(type); if (!allowed) throw new Error("Tipo de documento no válido"); const { iv, cipher } = await encryptBytes(bytes); const hash = await sha256Hex(bytes); const storage = `${crypto.randomUUID()}.enc`; - const dir = companyDir(companyId); - await mkdir(dir, { recursive: true }); - await Deno.writeFile(join(dir, storage), cipher); - db.prepare("UPDATE company_documents SET is_current = 0 WHERE company_id = ? AND type_code = ?").run( + await putObject(companyDocKey(companyId, storage), cipher); + await db.prepare("UPDATE company_documents SET is_current = false WHERE company_id = ? AND type_code = ?").run( companyId, type, ); - db.prepare( + await db.prepare( `INSERT INTO company_documents - (company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?)`, + (company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`, ).run(companyId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); } @@ -364,19 +357,19 @@ async function fetchPhoto(url: string): Promise { } } -function upsertWorker( - db: Database, +async function upsertWorker( + db: Db, input: ReturnType & { company_id: number; tenant_id?: number | null }, projectId: number | null, ) { - const existing = findExisting(db, input.curp, input.rfc, input.nss); + const existing = await findExisting(db, input.curp, input.rfc, input.nss); if (existing) { - db.prepare( + await db.prepare( `UPDATE workers SET first_name=?, middle_name=?, last_name_p=?, last_name_m=?, curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?, hire_type=?, company_id=?, tenant_id=COALESCE(?, tenant_id), position=?, risk_code=?, work_type=?, daily_wage=?, - needs_badge=?, status=?, updated_at=datetime('now') + needs_badge=?, status=?, updated_at=now() WHERE id=?`, ).run( input.first_name, @@ -401,12 +394,12 @@ function upsertWorker( input.status, existing.id, ); - if (projectId) assign(db, existing.id, projectId); - refreshPipeline(db, existing.id); + if (projectId) await assign(db, existing.id, projectId); + await refreshPipeline(db, existing.id, input.tenant_id ?? null); const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS"; return { id: existing.id, action: "existed" as const, matched }; } - db.prepare( + await db.prepare( `INSERT INTO workers (first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id) @@ -433,19 +426,19 @@ function upsertWorker( input.status, input.tenant_id ?? null, ); - const id = lastInsertId(db); - if (projectId) assign(db, id, projectId); - refreshPipeline(db, id); + const id = await lastInsertId(db); + if (projectId) await assign(db, id, projectId); + await refreshPipeline(db, id, input.tenant_id ?? null); return { id, action: "inserted" as const, matched: null as string | null }; } -function assign(db: Database, workerId: number, projectId: number) { - db.prepare( +async function assign(db: Db, workerId: number, projectId: number) { + await db.prepare( `INSERT INTO assignments (worker_id, project_id, active, start_date) - VALUES (?, ?, 1, date('now')) + VALUES (?, ?, true, current_date) ON CONFLICT(worker_id, project_id) DO UPDATE SET - active=1, - start_date=CASE WHEN assignments.active=0 THEN excluded.start_date ELSE assignments.start_date END, + active=true, + start_date=CASE WHEN assignments.active=false THEN excluded.start_date ELSE assignments.start_date END, end_date=NULL`, ).run(workerId, projectId); } @@ -460,14 +453,14 @@ export type ImportReport = { }; export async function importExcel( - db: Database, + db: Db, bytes: Uint8Array, projectId: number | null, userId: number | null, ): Promise { const wb = XLSX.read(bytes, { type: "array" }); const risks = new Set( - (db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code), + (await db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code), ); const report: ImportReport = { inserted: 0, @@ -509,7 +502,7 @@ export async function importExcel( const nombre = [data["NOMBRE"], data["APELLIDO PATERNO"], data["APELLIDO MATERNO"]].filter(Boolean).join(" "); const input = rowToInput(data, job.fallback); const errors = validateWorkerFields(input); - const company = resolveCompany(db, input); + const company = await resolveCompany(db, input); if (!company) errors.hire_type = `Empresa no encontrada (${data["ALTA"] || "—"})`; if (input.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) { errors.email = "Correo inválido"; @@ -546,7 +539,7 @@ export async function importExcel( company_id: company!.id, tenant_id: company!.tenant_id ?? 1, }; - const res = upsertWorker(db, n, n.status === "activo" ? projectId : null); + const res = await upsertWorker(db, n, n.status === "activo" ? projectId : null); if (res.action === "inserted") report.inserted++; else { report.existed++; diff --git a/api/iam_db.ts b/api/iam_db.ts new file mode 100644 index 0000000..1ed16d4 --- /dev/null +++ b/api/iam_db.ts @@ -0,0 +1,72 @@ +import postgres, { createPool, PgDb, withTenant } from "./pg.ts"; +import { config } from "./config.ts"; + +/** + * panels_product, esquema iam: identidad/roles/permisos DE CADA TENANT. + * Aislado de `core` a propósito (ver reglas del monolito modular) -- este + * módulo no debe importar nada de db.ts (core) ni viceversa. + */ + +let appPool: postgres.Sql | null = null; +let authPool: postgres.Sql | null = null; + +function getAppPool(): postgres.Sql { + if (!appPool) { + appPool = createPool(config.databaseUrlIam, { max: 10 }); + } + return appPool; +} + +/** Pool con credenciales _owner: SOLO para resolver login por username (el + * username es único globalmente, no por tenant, así que hay que buscarlo + * ANTES de saber a qué tenant pertenece -- RLS con tenant_id no puede + * aplicar todavía en ese punto). El dueño de la tabla omite RLS de forma + * nativa en Postgres (a menos que se use FORCE ROW LEVEL SECURITY, que a + * propósito no se activó -- ver db/iam/changesets/002-rls.sql). No usar + * este pool para nada más que esa resolución puntual. */ +function getAuthPool(): postgres.Sql { + if (!authPool) { + authPool = createPool(config.databaseUrlIamOwner || config.databaseUrlIam, { max: 3 }); + } + return authPool; +} + +/** Health-check de arranque: falla rápido si Postgres no responde. */ +export async function pingIamDb(): Promise { + await getAppPool()`SELECT 1`; +} + +/** Busca un usuario por username en CUALQUIER tenant (paso previo al login, + * antes de conocer el tenant_id). Devuelve la fila cruda; el caller decide + * qué hacer con tenant_id/role_code. */ +export async function findUserByUsernameAnyTenant(username: string) { + const rows = await getAuthPool()` + SELECT id, username, password_hash, display_name, company_id, tenant_id, + role_code, must_change_password, email, created_at + FROM iam.users WHERE username = ${username}`; + return rows[0] as Record | undefined; +} + +/** Ejecuta `fn` con una conexión ya acotada por tenant_id via RLS (ver + * pg.ts#withTenant). Todo el resto del código de iam (fuera del login) + * debe pasar por aquí. */ +export async function withIamTenant( + tenantId: number | null, + fn: (db: PgDb) => Promise, +): Promise { + return await withTenant(getAppPool(), tenantId, fn); +} + +/** Uso puntual sin transacción/RLS explícito -- reservado para el bootstrap + * (scripts/bootstrap-admin.ts) y para lecturas del propio rol _owner que + * necesitan ver todos los tenants a propósito (soporte/administración). */ +export async function withIamOwner(fn: (db: PgDb) => Promise): Promise { + return await fn(new PgDb(getAuthPool())); +} + +export async function closeIamDb(): Promise { + await appPool?.end({ timeout: 5 }); + await authPool?.end({ timeout: 5 }); + appPool = null; + authPool = null; +} diff --git a/api/liquibase.ts b/api/liquibase.ts deleted file mode 100644 index a080270..0000000 --- a/api/liquibase.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { join } from "node:path"; -import { ROOT, DATA_DIR } from "./config.ts"; - -const LIQUIBASE = join(ROOT, "db", "tools", "liquibase", "liquibase"); -const SQLITE_JDBC = join(ROOT, "db", "tools", "sqlite-jdbc.jar"); -const BOOTSTRAP = join(ROOT, "db", "bootstrap-tools.sh"); - -async function ensureTools(): Promise { - try { - await Deno.stat(LIQUIBASE); - await Deno.stat(SQLITE_JDBC); - return true; - } catch { - const cmd = new Deno.Command("bash", { - args: [BOOTSTRAP], - stdout: "piped", - stderr: "piped", - }); - const { code, stdout, stderr } = await cmd.output(); - if (code !== 0) { - console.warn( - "[liquibase] bootstrap failed:\n", - new TextDecoder().decode(stdout), - new TextDecoder().decode(stderr), - ); - return false; - } - return true; - } -} - -async function updateOne(name: "app" | "platform"): Promise { - const dir = join(ROOT, "db", name); - const dbPath = join(DATA_DIR, `${name}.db`); - const cmd = new Deno.Command(LIQUIBASE, { - args: [ - "--defaultsFile=liquibase.properties", - `--classpath=${SQLITE_JDBC}`, - `--url=jdbc:sqlite:${dbPath}`, - "update", - ], - cwd: dir, - stdout: "piped", - stderr: "piped", - }); - const { code, stdout, stderr } = await cmd.output(); - const out = new TextDecoder().decode(stdout); - const err = new TextDecoder().decode(stderr); - if (code !== 0) { - throw new Error(`Liquibase update failed for ${name}:\n${out}\n${err}`); - } -} - -let ran = false; - -/** Apply Liquibase changelogs for app.db and platform.db (idempotent). */ -export async function runLiquibase(): Promise { - if (ran) return; - if (!await ensureTools()) { - console.warn("[liquibase] CLI missing; run ./db/update.sh"); - ran = true; - return; - } - await updateOne("app"); - await updateOne("platform"); - ran = true; -} diff --git a/api/mail.ts b/api/mail.ts index 2c15faa..b8bb4c3 100644 --- a/api/mail.ts +++ b/api/mail.ts @@ -18,8 +18,8 @@ export type MailPayload = { attachments?: MailAttachment[]; }; -export function smtpConfigured(platformDb: PlatformDb): boolean { - return smtpConfiguredFromDb(platformDb); +export async function smtpConfigured(platformDb: PlatformDb): Promise { + return await smtpConfiguredFromDb(platformDb); } /** Envía correo por SMTP. Si no hay SMTP, no falla: sent=false. */ @@ -27,11 +27,11 @@ export async function sendMail( platformDb: PlatformDb, payload: MailPayload, ): Promise<{ sent: boolean; error?: string; message_id?: string }> { - if (!smtpConfigured(platformDb)) { + if (!await smtpConfigured(platformDb)) { return { sent: false, error: "SMTP no configurado o deshabilitado" }; } - const s = resolveSmtp(platformDb); + const s = await resolveSmtp(platformDb); const from = (s.from_address ?? "").trim(); if (!from || !/(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(from)) { return { diff --git a/api/main.ts b/api/main.ts index 53639e6..cb401f1 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1,9 +1,37 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; -import { getDb, checklistFor, refreshPipeline, workerDir, lastInsertId, nextProjectCode, isProjectStatus, projectById, projectMustBe, PROJECT_STATUS_CATALOG, projectDir, projectChecklistFor, companyDir, companyChecklistFor, imssFlagsFor, checklistItemsFor } from "./db.ts"; +import { + checklistFor, + refreshPipeline, + nextProjectCode, + isProjectStatus, + projectById, + projectMustBe, + PROJECT_STATUS_CATALOG, + projectChecklistFor, + companyChecklistFor, + imssFlagsFor, + checklistItemsFor, + lastInsertId, + pingCoreDb, + tenantTimezone, + getCoreDb, + type Db, +} from "./db.ts"; import { config } from "./config.ts"; -import { clearSession, createSessionCookie, login, changePassword, requireAuth, requirePlatformAdmin, tenantScope, type AuthUser } from "./auth.ts"; -import { getPlatformDb } from "./platform_db.ts"; +import { + clearSession, + createSessionCookie, + login, + changePassword, + getFreshAppUser, + requireAuth, + requirePlatformAdmin, + tenantScope, + type AuthUser, +} from "./auth.ts"; +import { requireCoreAuth, withCoreScope } from "./scope.ts"; +import { getPlatformDb, pingPlatformDb } from "./platform_db.ts"; import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, updateTenant } from "./saas.ts"; import { smtpConfigured, testSmtp } from "./mail.ts"; import { saveSmtpSettings, smtpPublicView } from "./smtp.ts"; @@ -33,16 +61,18 @@ import { lineAmount, listBudget, } from "./budget.ts"; -import { join } from "node:path"; +import { companyDocKey, getObject, projectDocKey, workerDocKey } from "./storage.ts"; +import { cacheCore, cacheKeyCore } from "./cache.ts"; +import { pingRedis } from "./redis.ts"; -const app = new Hono<{ Variables: { user: AuthUser } }>(); +const app = new Hono<{ Variables: { user: AuthUser; db: Db } }>(); -function scopedCompany( - db: Awaited>, +async function scopedCompany( + db: Db, id: number, tid: number | null, ) { - const company = companyById(db, id); + const company = await companyById(db, id); if (!company) return undefined; if (tid != null && company.tenant_id != null && company.tenant_id !== tid) return undefined; return company; @@ -67,18 +97,29 @@ app.use( cors({ origin: corsOrigins(), credentials: true, - allowHeaders: ["Content-Type", "X-API-Key"], + allowHeaders: ["Content-Type", "X-API-Key", "X-Tenant-Id"], }), ); -app.get("/v1/health", (c) => c.json({ ok: true })); +// Fail-fast real (Fase 7): antes con SQLite la app "siempre arrancaba"; con +// Postgres/Redis, /v1/health de verdad toca las tres conexiones Postgres y +// las dos de Redis, no solo responde estático. +app.get("/v1/health", async (c) => { + const [core, platform, redis] = await Promise.all([ + pingCoreDb().then(() => true).catch(() => false), + pingPlatformDb().then(() => true).catch(() => false), + pingRedis(), + ]); + const ok = core && platform && redis.iam && redis.core; + return c.json({ ok, core, platform, redis }, ok ? 200 : 503); +}); app.post("/v1/auth/login", async (c) => { const body = await c.req.json<{ username?: string; password?: string }>(); try { const user = await login(body.username ?? "", body.password ?? ""); if (!user) return c.json({ error: "Usuario o contraseña incorrectos" }, 401); - await createSessionCookie(c, user.id, user.realm); + await createSessionCookie(c, user.id, user.realm, user.tenant_id); return c.json({ user }); } catch (e: unknown) { if (e instanceof Error && (e as { code?: string }).code === "TENANT_BLOCKED") { @@ -88,8 +129,8 @@ app.post("/v1/auth/login", async (c) => { } }); -app.post("/v1/auth/logout", (c) => { - clearSession(c); +app.post("/v1/auth/logout", async (c) => { + await clearSession(c); return c.json({ ok: true }); }); @@ -99,49 +140,35 @@ app.post("/v1/auth/change-password", requireAuth, async (c) => { const user = c.get("user"); if (user.realm !== "app") return c.json({ error: "No aplica" }, 400); const body = await c.req.json<{ current_password?: string; new_password?: string }>(); - const result = await changePassword(user.id, body.current_password ?? "", body.new_password ?? ""); + const result = await changePassword(user.id, user.tenant_id, body.current_password ?? "", body.new_password ?? ""); if (result.error) return c.json({ error: result.error }, 400); - const db = await getDb(); - const row = db.prepare( - `SELECT u.id, u.username, u.display_name, u.company_id, u.tenant_id, u.role, - COALESCE(u.must_change_password, 0) AS must_change_password, - c.code AS company_code, c.name AS company_name, c.kind AS company_kind - FROM users u LEFT JOIN companies c ON c.id = u.company_id WHERE u.id = ?`, - ).get(user.id) as Record; - return c.json({ - ok: true, - user: { - ...row, - must_change_password: false, - role: row.role === "tenant_admin" ? "tenant_admin" : "user", - realm: "app", - }, - }); + const fresh = await getFreshAppUser(user.id, user.tenant_id); + return c.json({ ok: true, user: fresh }); }); app.get("/v1/saas/tenants", requirePlatformAdmin, async (c) => { const pdb = await getPlatformDb(); - return c.json({ tenants: listTenants(pdb), smtp_configured: smtpConfigured(pdb) }); + return c.json({ tenants: await listTenants(pdb), smtp_configured: await smtpConfigured(pdb) }); }); app.get("/v1/saas/tenants/:id", requirePlatformAdmin, async (c) => { const id = Number(c.req.param("id")); const pdb = await getPlatformDb(); - const db = await getDb(); - const detail = getTenantDetail(pdb, db, id); + const core = await getCoreDb(); + const detail = await getTenantDetail(pdb, core, id); if (!detail) return c.json({ error: "Empresa no encontrada" }, 404); - return c.json({ tenant: detail, smtp_configured: smtpConfigured(pdb) }); + return c.json({ tenant: detail, smtp_configured: await smtpConfigured(pdb) }); }); app.get("/v1/saas/smtp", requirePlatformAdmin, async (c) => { const pdb = await getPlatformDb(); - return c.json({ smtp: smtpPublicView(pdb) }); + return c.json({ smtp: await smtpPublicView(pdb) }); }); app.put("/v1/saas/smtp", requirePlatformAdmin, async (c) => { const body = await c.req.json(); const pdb = await getPlatformDb(); - const result = saveSmtpSettings(pdb, { + const result = await saveSmtpSettings(pdb, { ...body, keep_password: body.password === undefined || body.password === "", }); @@ -150,9 +177,9 @@ app.put("/v1/saas/smtp", requirePlatformAdmin, async (c) => { }); app.post("/v1/saas/smtp/test", requirePlatformAdmin, async (c) => { - const body = await c.req.json<{ to?: string }>().catch(() => ({})); + const body = await c.req.json<{ to?: string }>().catch(() => ({} as { to?: string })); const pdb = await getPlatformDb(); - if (!smtpConfigured(pdb)) { + if (!await smtpConfigured(pdb)) { return c.json({ error: "Guarde y habilite SMTP antes de probar" }, 400); } const result = await testSmtp(pdb, body.to ?? ""); @@ -163,8 +190,8 @@ app.post("/v1/saas/smtp/test", requirePlatformAdmin, async (c) => { app.post("/v1/saas/tenants", requirePlatformAdmin, async (c) => { const body = await c.req.json(); const pdb = await getPlatformDb(); - const db = await getDb(); - const result = await createTenant(pdb, db, body); + const core = await getCoreDb(); + const result = await createTenant(pdb, core, body); if (result.error || !result.tenant) return c.json({ error: result.error }, 400); return c.json({ tenant: result.tenant, @@ -177,20 +204,22 @@ app.patch("/v1/saas/tenants/:id", requirePlatformAdmin, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); const pdb = await getPlatformDb(); - const db = await getDb(); - const result = updateTenant(pdb, db, id, body); + const core = await getCoreDb(); + const result = await updateTenant(pdb, core, id, body); if (result.error) return c.json({ error: result.error }, 400); - const detail = getTenantDetail(pdb, db, id); + const detail = await getTenantDetail(pdb, core, id); return c.json({ tenant: detail }); }); /** Regenera contraseña temporal del admin y opcionalmente la envía por correo. */ app.post("/v1/saas/tenants/:id/send-access", requirePlatformAdmin, async (c) => { const id = Number(c.req.param("id")); - const body = await c.req.json<{ user_id?: number; send_email?: boolean }>().catch(() => ({})); + const body = await c.req.json<{ user_id?: number; send_email?: boolean }>().catch(() => + ({} as { user_id?: number; send_email?: boolean }) + ); const pdb = await getPlatformDb(); - const db = await getDb(); - const result = await issueTenantAdminAccess(pdb, db, id, { + const core = await getCoreDb(); + const result = await issueTenantAdminAccess(pdb, core, id, { userId: body.user_id, send_email: body.send_email !== false, }); @@ -198,87 +227,120 @@ app.post("/v1/saas/tenants/:id/send-access", requirePlatformAdmin, async (c) => return c.json({ admin: result.admin, email: result.email, - smtp_configured: smtpConfigured(pdb), + smtp_configured: await smtpConfigured(pdb), }); }); -app.get("/v1/catalogs", requireAuth, async (c) => { - const db = await getDb(); +app.get("/v1/catalogs", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const user = c.get("user"); const tid = tenantScope(user); - return c.json({ - risks: db.prepare("SELECT * FROM risk_levels").all(), - themes: db.prepare("SELECT * FROM badge_themes").all(), - document_types: db.prepare("SELECT * FROM document_types").all(), - project_document_types: db.prepare("SELECT * FROM project_document_types").all(), - company_document_types: db.prepare("SELECT * FROM company_document_types").all(), + // Catálogos de baja escritura (Fase 4e): TTL corto, sin invalidación + // explícita. La llave incluye tenant_id porque `companies` sí es dato + // de negocio por-tenant -- risk_levels/badge_themes/document_types son + // globales, pero se cachean juntos por simplicidad de esta única + // respuesta agregada. + const payload = await cacheCore(cacheKeyCore(tid, "catalogs"), 30, async () => ({ + risks: await db.prepare("SELECT * FROM risk_levels").all(), + themes: await db.prepare("SELECT * FROM badge_themes").all(), + document_types: await db.prepare("SELECT * FROM document_types").all(), + project_document_types: await db.prepare("SELECT * FROM project_document_types").all(), + company_document_types: await db.prepare("SELECT * FROM company_document_types").all(), project_statuses: PROJECT_STATUS_CATALOG, - companies: listCompanies(db, tid), - }); + companies: await listCompanies(db, tid), + })); + return c.json(payload); }); -app.get("/v1/companies", requireAuth, async (c) => { - const db = await getDb(); +// Configuración del tenant (Fase 4d): zona horaria usada por nómina y +// vigencia de documentos -- reemplaza el PAYROLL_TZ hardcodeado. +app.get("/v1/configuracion", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const tid = tenantScope(c.get("user")); - return c.json({ companies: listCompanies(db, tid) }); + const timezone = await tenantTimezone(db, tid); + return c.json({ timezone }); }); -app.post("/v1/companies", requireAuth, async (c) => { +app.put("/v1/configuracion", ...requireCoreAuth, async (c) => { + const user = c.get("user"); + if (user.role !== "tenant_admin") return c.json({ error: "Solo administradores" }, 403); + const db = c.get("db"); + const tid = tenantScope(user); + if (tid == null) return c.json({ error: "Cuenta sin tenant" }, 400); + const body = await c.req.json<{ timezone?: string }>(); + const tz = (body.timezone ?? "").trim(); + const valid = new Set(Intl.supportedValuesOf("timeZone")); + if (!tz || !valid.has(tz)) return c.json({ error: "Zona horaria inválida" }, 400); + await db.prepare( + `INSERT INTO tenant_settings (tenant_id, timezone, updated_at) VALUES (?, ?, now()) + ON CONFLICT (tenant_id) DO UPDATE SET timezone = excluded.timezone, updated_at = now()`, + ).run(tid, tz); + return c.json({ timezone: tz }); +}); + +app.get("/v1/companies", ...requireCoreAuth, async (c) => { + const db = c.get("db"); + const tid = tenantScope(c.get("user")); + return c.json({ companies: await listCompanies(db, tid) }); +}); + +app.post("/v1/companies", ...requireCoreAuth, async (c) => { const body = await c.req.json(); - const db = await getDb(); + const db = c.get("db"); const tid = tenantScope(c.get("user")); - const result = createSubcompany(db, body, tid); + const result = await createSubcompany(db, body, tid); if (result.error || !result.company) return c.json({ error: result.error }, 400); return c.json({ company: result.company }, 201); }); -app.patch("/v1/companies/:id", requireAuth, async (c) => { +app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); - const db = await getDb(); - const result = updateCompany(db, id, body); + const db = c.get("db"); + const tid = tenantScope(c.get("user")); + const result = await updateCompany(db, id, body, tid); if (result.error) return c.json({ error: result.error }, (result.status ?? 400) as 400 | 404); return c.json({ company: result.company }); }); -app.get("/v1/companies/:id", requireAuth, async (c) => { +app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const company = scopedCompany(db, id, tenantScope(c.get("user"))); + const db = c.get("db"); + const company = await scopedCompany(db, id, tenantScope(c.get("user"))); if (!company) return c.json({ error: "Empresa no encontrada" }, 404); - const documents = db.prepare( + const documents = await db.prepare( "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM company_documents WHERE company_id = ? ORDER BY uploaded_at DESC", ).all(id); return c.json({ company, documents, - checklist: companyChecklistFor(db, id), + checklist: await companyChecklistFor(db, id), }); }); -app.post("/v1/companies/:id/documents", requireAuth, async (c) => { +app.post("/v1/companies/:id/documents", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const type = String(form.get("type") || ""); const file = form.get("file"); if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400); - const db = await getDb(); - if (!scopedCompany(db, id, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); + const db = c.get("db"); + if (!await scopedCompany(db, id, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); const bytes = new Uint8Array(await file.arrayBuffer()); try { await storeCompanyDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id); } catch (error) { return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar el documento" }, 400); } - return c.json({ ok: true, checklist: companyChecklistFor(db, id) }); + return c.json({ ok: true, checklist: await companyChecklistFor(db, id) }); }); -app.get("/v1/companies/:id/documents/:docId", requireAuth, async (c) => { +app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => { const companyId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); - const db = await getDb(); - if (!scopedCompany(db, companyId, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); - const doc = db.prepare( + const db = c.get("db"); + if (!await scopedCompany(db, companyId, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); + const doc = await db.prepare( "SELECT * FROM company_documents WHERE id=? AND company_id=?", ).get(docId, companyId) as { storage_name: string; @@ -287,15 +349,20 @@ app.get("/v1/companies/:id/documents/:docId", requireAuth, async (c) => { original_name: string; } | undefined; if (!doc) return c.json({ error: "Documento no encontrado" }, 404); - const enc = await Deno.readFile(join(companyDir(companyId), doc.storage_name)); + const enc = await getObject(companyDocKey(companyId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); - c.header("Content-Type", doc.mime); - c.header("Content-Disposition", `inline; filename="${doc.original_name}"`); + // Documentos subidos por el usuario se sirven como adjunto, nunca inline: + // servir "inline" dejaría que el navegador renderice el Content-Type que + // el propio uploader eligió (ej. un .html disfrazado de "comprobante"), + // habilitando XSS almacenado dentro del origen autenticado de la app. + c.header("Content-Type", "application/octet-stream"); + c.header("X-Content-Type-Options", "nosniff"); + c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); return c.body(plain.buffer as ArrayBuffer); }); -app.get("/v1/projects", requireAuth, async (c) => { - const db = await getDb(); +app.get("/v1/projects", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const tid = tenantScope(c.get("user")); const status = (c.req.query("status") ?? "").trim(); const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : []; @@ -311,13 +378,13 @@ app.get("/v1/projects", requireAuth, async (c) => { } const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; return c.json({ - projects: db.prepare( + projects: await db.prepare( `SELECT p.*, c.name AS company_name, c.code AS company_code, - (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = 1) AS active_count, + (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count, (SELECT COUNT(*) FROM project_document_types t - WHERE t.required = 1 AND NOT EXISTS ( + WHERE t.required = true AND NOT EXISTS ( SELECT 1 FROM project_documents d - WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = 1 + WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = true )) AS missing_docs, (SELECT COUNT(*) FROM budget_items b WHERE b.project_id = p.id) AS budget_count FROM projects p @@ -375,22 +442,22 @@ function projectFields(body: ProjectInput, cur?: Record) { }; } -app.post("/v1/projects", requireAuth, async (c) => { +app.post("/v1/projects", ...requireCoreAuth, async (c) => { const body = await c.req.json(); if (!body.name?.trim()) return c.json({ error: "Nombre de proyecto obligatorio" }, 400); const status = body.status && isProjectStatus(body.status) ? body.status : "activo"; - const db = await getDb(); + const db = c.get("db"); const fields = projectFields(body); if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); - if (!resolveCompany(db, { company_id: fields.company_id })) { + if (!await resolveCompany(db, { company_id: fields.company_id })) { return c.json({ error: "Empresa no encontrada" }, 400); } if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) { return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400); } - const code = nextProjectCode(db); + const code = await nextProjectCode(db); const tid = tenantScope(c.get("user")); - db.prepare( + await db.prepare( `INSERT INTO projects ( code, name, address, theme_id, status, company_id, contract_amount, start_date, end_date, resident_name, siroc, payroll_tax_pct, tenant_id @@ -410,26 +477,28 @@ app.post("/v1/projects", requireAuth, async (c) => { fields.payroll_tax_pct, tid, ); - const id = lastInsertId(db); + const id = await lastInsertId(db); return c.json({ id, code, status }, 201); }); -app.patch("/v1/projects/:id", requireAuth, async (c) => { +app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); - const db = await getDb(); - const cur = db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Record | undefined; + const db = c.get("db"); + // Con RLS activo (db/core/changesets/005-rls.sql), esta consulta ya no + // puede traer una fila de otro tenant: la conexión solo ve app.tenant_id. + const cur = await db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Record | undefined; if (!cur) return c.json({ error: "Proyecto no encontrado" }, 404); const status = body.status && isProjectStatus(body.status) ? body.status : String(cur.status || "activo"); const fields = projectFields(body, cur); if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); - if (!resolveCompany(db, { company_id: fields.company_id })) { + if (!await resolveCompany(db, { company_id: fields.company_id })) { return c.json({ error: "Empresa no encontrada" }, 400); } if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) { return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400); } - db.prepare( + await db.prepare( `UPDATE projects SET name=?, address=?, theme_id=?, status=?, company_id=?, contract_amount=?, start_date=?, end_date=?, resident_name=?, siroc=?, payroll_tax_pct=? WHERE id=?`, ).run( @@ -449,49 +518,49 @@ app.patch("/v1/projects/:id", requireAuth, async (c) => { return c.json({ ok: true, id, code: cur.code, status }); }); -app.get("/v1/projects/:id", requireAuth, async (c) => { +app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const project = db.prepare( + const db = c.get("db"); + const project = await db.prepare( `SELECT p.*, c.name AS company_name, c.code AS company_code, - (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = 1) AS active_count + (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count FROM projects p LEFT JOIN companies c ON c.id = p.company_id WHERE p.id = ?`, ).get(id); if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); - const documents = db.prepare( + const documents = await db.prepare( "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM project_documents WHERE project_id = ? ORDER BY uploaded_at DESC", ).all(id); return c.json({ project, documents, - checklist: projectChecklistFor(db, id), + checklist: await projectChecklistFor(db, id), }); }); -app.post("/v1/projects/:id/documents", requireAuth, async (c) => { +app.post("/v1/projects/:id/documents", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const type = String(form.get("type") || ""); const file = form.get("file"); if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400); - const db = await getDb(); - if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const db = c.get("db"); + if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); const bytes = new Uint8Array(await file.arrayBuffer()); try { await storeProjectDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id); } catch (error) { return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar el documento" }, 400); } - return c.json({ ok: true, checklist: projectChecklistFor(db, id) }); + return c.json({ ok: true, checklist: await projectChecklistFor(db, id) }); }); -app.get("/v1/projects/:id/documents/:docId", requireAuth, async (c) => { +app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => { const projectId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); - const db = await getDb(); - const doc = db.prepare( + const db = c.get("db"); + const doc = await db.prepare( "SELECT * FROM project_documents WHERE id=? AND project_id=?", ).get(docId, projectId) as { storage_name: string; @@ -500,40 +569,41 @@ app.get("/v1/projects/:id/documents/:docId", requireAuth, async (c) => { original_name: string; } | undefined; if (!doc) return c.json({ error: "Documento no encontrado" }, 404); - const enc = await Deno.readFile(join(projectDir(projectId), doc.storage_name)); + const enc = await getObject(projectDocKey(projectId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); - c.header("Content-Type", doc.mime); - c.header("Content-Disposition", `inline; filename="${doc.original_name}"`); + c.header("Content-Type", "application/octet-stream"); + c.header("X-Content-Type-Options", "nosniff"); + c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); return c.body(plain.buffer as ArrayBuffer); }); -app.get("/v1/projects/:id/budget", requireAuth, async (c) => { +app.get("/v1/projects/:id/budget", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - return c.json(listBudget(db, id)); + const db = c.get("db"); + if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + return c.json(await listBudget(db, id)); }); -app.get("/v1/projects/:id/budget/template", requireAuth, (c) => { +app.get("/v1/projects/:id/budget/template", ...requireCoreAuth, (c) => { const bytes = buildBudgetTemplate(); c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); c.header("Content-Disposition", 'attachment; filename="plantilla-presupuesto.xlsx"'); return c.body(bytes.slice()); }); -app.get("/v1/projects/:id/budget/export", requireAuth, async (c) => { +app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const project = projectById(db, id); + const db = c.get("db"); + const project = await projectById(db, id); if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); - const budget = listBudget(db, id); + const budget = await listBudget(db, id); const bytes = exportBudgetWorkbook(project.name, budget); c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); c.header("Content-Disposition", `attachment; filename="presupuesto-${project.code}.xlsx"`); return c.body(bytes.slice()); }); -app.post("/v1/budget/preview", requireAuth, async (c) => { +app.post("/v1/budget/preview", ...requireCoreAuth, async (c) => { const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); @@ -543,57 +613,57 @@ app.post("/v1/budget/preview", requireAuth, async (c) => { return c.json(preview); }); -app.post("/v1/projects/:id/budget/preview", requireAuth, async (c) => { +app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); - const db = await getDb(); - if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const db = c.get("db"); + if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); const bytes = new Uint8Array(await file.arrayBuffer()); const preview = previewBudgetExcel(bytes); if (preview.errors.length && !preview.items.length) return c.json(preview, 400); return c.json(preview); }); -app.post("/v1/projects/:id/budget/import", requireAuth, async (c) => { +app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); - const db = await getDb(); - if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const db = c.get("db"); + if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); const bytes = new Uint8Array(await file.arrayBuffer()); - const report = importBudgetExcel(db, id, bytes); + const report = await importBudgetExcel(db, id, bytes); if (report.errors.length && !report.inserted) return c.json(report, 400); return c.json(report); }); -app.post("/v1/projects/:id/budget/chapters", requireAuth, async (c) => { +app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>(); if (!body.name?.trim()) return c.json({ error: "Nombre de capítulo obligatorio" }, 400); - const db = await getDb(); - if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - const sort = (db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_chapters WHERE project_id = ?").get(id) as { n: number }).n; - db.prepare("INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)").run( + const db = c.get("db"); + if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const sort = (await db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_chapters WHERE project_id = ?").get(id) as { n: number }).n; + await db.prepare("INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)").run( id, body.parent_id || null, (body.code || "").trim(), body.name.trim(), sort, ); - return c.json({ id: lastInsertId(db) }, 201); + return c.json({ id: await lastInsertId(db) }, 201); }); -app.patch("/v1/projects/:id/budget/chapters/:cid", requireAuth, async (c) => { +app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const cid = Number(c.req.param("cid")); const body = await c.req.json<{ name?: string; code?: string }>(); - const db = await getDb(); - const cur = db.prepare("SELECT * FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id) as { name: string; code: string } | undefined; + const db = c.get("db"); + const cur = await db.prepare("SELECT * FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id) as { name: string; code: string } | undefined; if (!cur) return c.json({ error: "Capítulo no encontrado" }, 404); - db.prepare("UPDATE budget_chapters SET name = ?, code = ? WHERE id = ?").run( + await db.prepare("UPDATE budget_chapters SET name = ?, code = ? WHERE id = ?").run( (body.name ?? cur.name).trim(), (body.code ?? cur.code).trim(), cid, @@ -601,14 +671,14 @@ app.patch("/v1/projects/:id/budget/chapters/:cid", requireAuth, async (c) => { return c.json({ ok: true }); }); -app.delete("/v1/projects/:id/budget/chapters/:cid", requireAuth, async (c) => { +app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const cid = Number(c.req.param("cid")); - const db = await getDb(); - const found = db.prepare("SELECT id FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id); + const db = c.get("db"); + const found = await db.prepare("SELECT id FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id); if (!found) return c.json({ error: "Capítulo no encontrado" }, 404); - db.prepare("UPDATE budget_items SET chapter_id = NULL WHERE project_id = ? AND chapter_id = ?").run(id, cid); - db.prepare("DELETE FROM budget_chapters WHERE id = ? AND project_id = ?").run(cid, id); + await db.prepare("UPDATE budget_items SET chapter_id = NULL WHERE project_id = ? AND chapter_id = ?").run(id, cid); + await db.prepare("DELETE FROM budget_chapters WHERE id = ? AND project_id = ?").run(cid, id); return c.json({ ok: true }); }); @@ -621,16 +691,16 @@ type BudgetItemInput = { unit_price?: number; }; -app.post("/v1/projects/:id/budget/items", requireAuth, async (c) => { +app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); if (!body.description?.trim()) return c.json({ error: "Descripción de partida obligatoria" }, 400); - const db = await getDb(); - if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const db = c.get("db"); + if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); const qty = Number(body.quantity ?? 0); const price = Number(body.unit_price ?? 0); - const sort = (db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_items WHERE project_id = ?").get(id) as { n: number }).n; - db.prepare( + const sort = (await db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_items WHERE project_id = ?").get(id) as { n: number }).n; + await db.prepare( `INSERT INTO budget_items (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( @@ -644,19 +714,19 @@ app.post("/v1/projects/:id/budget/items", requireAuth, async (c) => { lineAmount(qty, price), sort, ); - return c.json({ id: lastInsertId(db) }, 201); + return c.json({ id: await lastInsertId(db) }, 201); }); -app.patch("/v1/projects/:id/budget/items/:iid", requireAuth, async (c) => { +app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const iid = Number(c.req.param("iid")); const body = await c.req.json(); - const db = await getDb(); - const cur = db.prepare("SELECT * FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id) as Record | undefined; + const db = c.get("db"); + const cur = await db.prepare("SELECT * FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id) as Record | undefined; if (!cur) return c.json({ error: "Partida no encontrada" }, 404); const qty = body.quantity !== undefined ? Number(body.quantity) : Number(cur.quantity); const price = body.unit_price !== undefined ? Number(body.unit_price) : Number(cur.unit_price); - db.prepare( + await db.prepare( `UPDATE budget_items SET chapter_id=?, code=?, description=?, unit=?, quantity=?, unit_price=?, amount=? WHERE id=?`, ).run( body.chapter_id !== undefined ? (body.chapter_id || null) : cur.chapter_id, @@ -671,20 +741,20 @@ app.patch("/v1/projects/:id/budget/items/:iid", requireAuth, async (c) => { return c.json({ ok: true }); }); -app.delete("/v1/projects/:id/budget/items/:iid", requireAuth, async (c) => { +app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const iid = Number(c.req.param("iid")); - const db = await getDb(); - const found = db.prepare("SELECT id FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id); + const db = c.get("db"); + const found = await db.prepare("SELECT id FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id); if (!found) return c.json({ error: "Partida no encontrada" }, 404); - db.prepare("DELETE FROM budget_items WHERE id = ? AND project_id = ?").run(iid, id); + await db.prepare("DELETE FROM budget_items WHERE id = ? AND project_id = ?").run(iid, id); return c.json({ ok: true }); }); -app.post("/v1/workers/validate", requireAuth, async (c) => { +app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); - const db = await getDb(); + const db = c.get("db"); const exclude = body.id ?? 0; const checks: { field: string; value: string; err: string | null }[] = [ { field: "curp", value: normUpper(body.curp), err: validateCurp(body.curp ?? "") }, @@ -693,7 +763,7 @@ app.post("/v1/workers/validate", requireAuth, async (c) => { ]; for (const ch of checks) { if (ch.err) continue; - const row = db.prepare( + const row = await db.prepare( `SELECT id, first_name, last_name_p FROM workers WHERE ${ch.field} = ? AND id != ?`, ).get(ch.value, exclude) as { id: number; first_name: string; last_name_p: string } | undefined; if (row) { @@ -704,8 +774,8 @@ app.post("/v1/workers/validate", requireAuth, async (c) => { return c.json({ ok: Object.keys(errors).length === 0, errors }); }); -app.get("/v1/workers", requireAuth, async (c) => { - const db = await getDb(); +app.get("/v1/workers", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const tid = tenantScope(c.get("user")); const q = (c.req.query("q") ?? "").trim(); const status = c.req.query("status"); @@ -713,19 +783,19 @@ app.get("/v1/workers", requireAuth, async (c) => { let sql = `SELECT w.*, r.label AS risk_label, r.color AS risk_color, r.text_color AS risk_text, c.name AS company_name, c.kind AS company_kind, ic.name AS imss_company_name, ic.code AS imss_company_code, ic.registro_patronal AS imss_registro_patronal, - (SELECT GROUP_CONCAT(p.name, ', ') FROM assignments a JOIN projects p ON p.id = a.project_id - WHERE a.worker_id = w.id AND a.active = 1) AS proyectos, - (SELECT GROUP_CONCAT(p.code || '|' || replace(p.name, '|', '/'), ';;') FROM assignments a JOIN projects p ON p.id = a.project_id - WHERE a.worker_id = w.id AND a.active = 1) AS proyecto_pairs, - (SELECT GROUP_CONCAT(p.id, ',') FROM assignments a JOIN projects p ON p.id = a.project_id - WHERE a.worker_id = w.id AND a.active = 1) AS project_ids, + (SELECT STRING_AGG(p.name, ', ') FROM assignments a JOIN projects p ON p.id = a.project_id + WHERE a.worker_id = w.id AND a.active = true) AS proyectos, + (SELECT STRING_AGG(p.code || '|' || replace(p.name, '|', '/'), ';;') FROM assignments a JOIN projects p ON p.id = a.project_id + WHERE a.worker_id = w.id AND a.active = true) AS proyecto_pairs, + (SELECT STRING_AGG(p.id::text, ',') FROM assignments a JOIN projects p ON p.id = a.project_id + WHERE a.worker_id = w.id AND a.active = true) AS project_ids, (SELECT COUNT(*) FROM document_types t - WHERE t.required = 1 AND NOT EXISTS ( + WHERE t.required = true AND NOT EXISTS ( SELECT 1 FROM documents d - WHERE d.worker_id = w.id AND d.type_code = t.code AND d.is_current = 1 + WHERE d.worker_id = w.id AND d.type_code = t.code AND d.is_current = true )) AS missing_docs, (SELECT COALESCE(SUM(l.balance), 0) FROM loans l WHERE l.worker_id = w.id AND l.balance > 0) AS loan_balance, - CASE WHEN EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.active = 1) THEN 1 ELSE 0 END AS in_project + CASE WHEN EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.active = true) THEN true ELSE false END AS in_project FROM workers w JOIN risk_levels r ON r.code = w.risk_code LEFT JOIN companies c ON c.id = w.company_id @@ -743,34 +813,35 @@ app.get("/v1/workers", requireAuth, async (c) => { } if (projectId) { sql += - " AND EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.project_id = ? AND a.active = 1)"; + " AND EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.project_id = ? AND a.active = true)"; params.push(Number(projectId)); } if (q) { sql += - " AND (w.first_name LIKE ? OR w.last_name_p LIKE ? OR w.curp LIKE ? OR w.rfc LIKE ? OR w.nss LIKE ?)"; + " AND (w.first_name ILIKE ? OR w.last_name_p ILIKE ? OR w.curp ILIKE ? OR w.rfc ILIKE ? OR w.nss ILIKE ?)"; const like = `%${q}%`; params.push(like, like, like, like, like); } sql += " ORDER BY w.last_name_p, w.first_name"; - const rows = db.prepare(sql).all(...params) as Array & { + const rows = await db.prepare(sql).all(...params) as Array & { id: number; status: string; pipeline_status: string; imss_status?: string; - in_project?: number; + in_project?: boolean; }>; - const workers = rows.map((row) => { - const flags = imssFlagsFor(db, row.id); - return { + const workers = []; + for (const row of rows) { + const flags = await imssFlagsFor(db, row.id, tid); + workers.push({ ...row, imss_status: flags.imss_status, imss_ready: flags.imss_ready, in_project_without_imss: flags.in_project_without_imss, expediente_ok: flags.expediente_ok, freshness_required: flags.freshness_required, - }; - }); + }); + } const active = workers.filter((w) => w.status === "activo" && w.pipeline_status !== "baja"); const withImss = active.filter((w) => w.imss_status === "alta").length; const withoutImss = active.length - withImss; @@ -787,10 +858,11 @@ app.get("/v1/workers", requireAuth, async (c) => { }); }); -app.get("/v1/workers/:id", requireAuth, async (c) => { +app.get("/v1/workers/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const worker = db.prepare( + const db = c.get("db"); + const tid = tenantScope(c.get("user")); + const worker = await db.prepare( `SELECT w.*, r.label AS risk_label, r.color AS risk_color, r.text_color AS risk_text, c.name AS company_name, c.kind AS company_kind, ic.name AS imss_company_name, ic.code AS imss_company_code, ic.registro_patronal AS imss_registro_patronal @@ -801,20 +873,20 @@ app.get("/v1/workers/:id", requireAuth, async (c) => { WHERE w.id = ?`, ).get(id); if (!worker) return c.json({ error: "No encontrado" }, 404); - const documents = db.prepare( + const documents = await db.prepare( `SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at, issued_at, expires_at, imss_company_id, imss_alta_at FROM documents WHERE worker_id = ? ORDER BY uploaded_at DESC`, ).all(id); - const assignments = db.prepare( + const assignments = await db.prepare( `SELECT a.*, p.name AS project_name, p.code AS project_code FROM assignments a JOIN projects p ON p.id = a.project_id WHERE a.worker_id = ? ORDER BY a.active DESC, a.start_date DESC, a.id DESC`, ).all(id); - const loans = db.prepare("SELECT * FROM loans WHERE worker_id = ? ORDER BY id DESC").all(id); - const { items, freshness_required } = checklistItemsFor(db, id); - const flags = imssFlagsFor(db, id); - const docTypes = db.prepare( + const loans = await db.prepare("SELECT * FROM loans WHERE worker_id = ? ORDER BY id DESC").all(id); + const { items, freshness_required } = await checklistItemsFor(db, id, tid); + const flags = await imssFlagsFor(db, id, tid); + const docTypes = await db.prepare( `SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category FROM document_types ORDER BY required DESC, label`, ).all(); @@ -829,8 +901,7 @@ app.get("/v1/workers/:id", requireAuth, async (c) => { }); }); -function conflict(db: Awaited>, n: ReturnType, excludeId = 0) { - const row = findExisting(db, n.curp, n.rfc, n.nss); +function conflict(row: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined, n: ReturnType, excludeId = 0) { if (row && row.id !== excludeId) { const field = row.curp === n.curp ? "CURP" : row.rfc === n.rfc ? "RFC" : "NSS"; return { @@ -844,23 +915,25 @@ function conflict(db: Awaited>, n: ReturnType { +app.post("/v1/workers", ...requireCoreAuth, async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); - const db = await getDb(); + const db = c.get("db"); if (Object.keys(errors).length) return c.json({ errors }, 400); const n = normalizeWorker({ ...body, hire_type: "" }); - const cf = conflict(db, n); + const existing = await findExisting(db, n.curp, n.rfc, n.nss); + const cf = conflict(existing, n); if (cf) return c.json(cf.body, cf.status); if (body.project_id) { const blocked = projectMustBe( - projectById(db, body.project_id), + await projectById(db, body.project_id), ["activo"], "Solo se asigna personal a proyectos activos", ); if (blocked) return c.json({ error: blocked.error }, blocked.status); } - db.prepare( + const tid = tenantScope(c.get("user")); + await db.prepare( `INSERT INTO workers (first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id) @@ -868,20 +941,21 @@ app.post("/v1/workers", requireAuth, async (c) => { ).run( n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss, n.phone, n.email, n.address, n.blood_type, "", null, n.position, n.risk_code, - n.work_type, n.daily_wage, n.needs_badge, n.status, tenantScope(c.get("user")), + n.work_type, n.daily_wage, n.needs_badge, n.status, tid, ); - const id = lastInsertId(db); + const id = await lastInsertId(db); if (body.project_id) { - assign(db, id, body.project_id); + await assign(db, id, body.project_id); } - refreshPipeline(db, id); + await refreshPipeline(db, id, tid); return c.json({ id }, 201); }); -app.patch("/v1/workers/:id", requireAuth, async (c) => { +app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const cur = db.prepare("SELECT * FROM workers WHERE id = ?").get(id) as Record | undefined; + const db = c.get("db"); + const tid = tenantScope(c.get("user")); + const cur = await db.prepare("SELECT * FROM workers WHERE id = ?").get(id) as Record | undefined; if (!cur) return c.json({ error: "No encontrado" }, 404); const body = await c.req.json(); const merged = { ...cur, ...body } as WorkerInput; @@ -891,85 +965,88 @@ app.patch("/v1/workers/:id", requireAuth, async (c) => { const hireType = String(cur.hire_type || ""); const companyId = (cur.company_id as number | null) ?? null; const n = normalizeWorker({ ...merged, hire_type: hireType }); - const cf = conflict(db, n, id); + const existing = await findExisting(db, n.curp, n.rfc, n.nss); + const cf = conflict(existing, n, id); if (cf) return c.json(cf.body, cf.status); - db.prepare( + await db.prepare( `UPDATE workers SET first_name=?, middle_name=?, last_name_p=?, last_name_m=?, curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?, hire_type=?, company_id=?, position=?, risk_code=?, - work_type=?, daily_wage=?, needs_badge=?, status=?, updated_at=datetime('now') + work_type=?, daily_wage=?, needs_badge=?, status=?, updated_at=now() WHERE id=?`, ).run( n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss, n.phone, n.email, n.address, n.blood_type, hireType, companyId, n.position, n.risk_code, n.work_type, n.daily_wage, n.needs_badge, n.status, id, ); - refreshPipeline(db, id); + await refreshPipeline(db, id, tid); return c.json({ ok: true }); }); -app.patch("/v1/workers/:id/pipeline", requireAuth, async (c) => { +app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const { pipeline_status } = await c.req.json<{ pipeline_status: string }>(); const allowed = ["incompleto", "listo_gafete", "impreso", "activo", "baja"]; if (!allowed.includes(pipeline_status)) return c.json({ error: "Estado inválido" }, 400); - const db = await getDb(); - const prev = db.prepare("SELECT status FROM workers WHERE id=?").get(id) as { status: string } | undefined; + const db = c.get("db"); + const tid = tenantScope(c.get("user")); + const prev = await db.prepare("SELECT status FROM workers WHERE id=?").get(id) as { status: string } | undefined; if (pipeline_status === "baja") { - db.prepare("UPDATE workers SET status='baja', pipeline_status='baja' WHERE id=?").run(id); + await db.prepare("UPDATE workers SET status='baja', pipeline_status='baja' WHERE id=?").run(id); } else { // Reactivar: si venía de baja, marca rehire para exigir docs frescos hasta nueva alta IMSS. if (prev?.status === "baja") { - db.prepare( - "UPDATE workers SET status='activo', last_rehire_at=date('now') WHERE id=?", + await db.prepare( + "UPDATE workers SET status='activo', last_rehire_at=current_date WHERE id=?", ).run(id); } else { - db.prepare("UPDATE workers SET status='activo' WHERE id=?").run(id); + await db.prepare("UPDATE workers SET status='activo' WHERE id=?").run(id); } - refreshPipeline(db, id); + await refreshPipeline(db, id, tid); } - const worker = db.prepare("SELECT status, pipeline_status, last_rehire_at, imss_status FROM workers WHERE id=?").get(id); + const worker = await db.prepare("SELECT status, pipeline_status, last_rehire_at, imss_status FROM workers WHERE id=?").get(id); return c.json({ ok: true, worker }); }); -app.post("/v1/workers/:id/assign", requireAuth, async (c) => { +app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>(); - const db = await getDb(); + const db = c.get("db"); + const tid = tenantScope(c.get("user")); if (active === false) { - db.prepare( - "UPDATE assignments SET active=0, end_date=date('now') WHERE worker_id=? AND project_id=?", + await db.prepare( + "UPDATE assignments SET active=false, end_date=current_date WHERE worker_id=? AND project_id=?", ).run(id, project_id); } else { - const project = projectById(db, project_id); + const project = await projectById(db, project_id); if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); if (project.status !== "activo") { return c.json({ error: "Solo se asigna personal a proyectos activos" }, 400); } - assign(db, id, project_id); + await assign(db, id, project_id); } - refreshPipeline(db, id); + await refreshPipeline(db, id, tid); return c.json({ ok: true }); }); -app.get("/v1/workers/import/template", requireAuth, async (c) => { - const db = await getDb(); - const bytes = buildImportTemplate(listCompanies(db)); +app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => { + const db = c.get("db"); + const bytes = buildImportTemplate(await listCompanies(db)); c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); c.header("Content-Disposition", 'attachment; filename="plantilla-padron-arctec.xlsx"'); return c.body(bytes.buffer as ArrayBuffer); }); -app.post("/v1/workers/import", requireAuth, async (c) => { +app.post("/v1/workers/import", ...requireCoreAuth, async (c) => { const form = await c.req.formData(); const file = form.get("file"); const projectId = Number(form.get("project_id") || 0) || null; if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); const bytes = new Uint8Array(await file.arrayBuffer()); - const db = await getDb(); + const db = c.get("db"); if (projectId) { const blocked = projectMustBe( - projectById(db, projectId), + await projectById(db, projectId), ["activo"], "Solo se importan altas a proyectos activos", ); @@ -979,22 +1056,22 @@ app.post("/v1/workers/import", requireAuth, async (c) => { return c.json(report); }); -app.post("/v1/workers/:id/documents", requireAuth, async (c) => { +app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const type = String(form.get("type") || ""); const file = form.get("file"); if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400); - const db = await getDb(); - const exists = db.prepare("SELECT id FROM workers WHERE id=?").get(id); + const db = c.get("db"); + const exists = await db.prepare("SELECT id FROM workers WHERE id=?").get(id); if (!exists) return c.json({ error: "No encontrado" }, 404); - const policy = db.prepare( + const policy = await db.prepare( `SELECT validity_mode, requires_issued_at, requires_expires_at FROM document_types WHERE code = ?`, ).get(type) as { validity_mode: string; - requires_issued_at: number; - requires_expires_at: number; + requires_issued_at: boolean; + requires_expires_at: boolean; } | undefined; if (!policy) return c.json({ error: "Tipo de documento no válido" }, 400); @@ -1032,18 +1109,19 @@ app.post("/v1/workers/:id/documents", requireAuth, async (c) => { } catch (error) { return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar" }, 400); } + const tid = tenantScope(c.get("user")); return c.json({ ok: true, - checklist: checklistFor(db, id), - ...imssFlagsFor(db, id), + checklist: await checklistFor(db, id, tid), + ...await imssFlagsFor(db, id, tid), }); }); -app.get("/v1/workers/:id/documents/:docId", requireAuth, async (c) => { +app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => { const workerId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); - const db = await getDb(); - const doc = db.prepare( + const db = c.get("db"); + const doc = await db.prepare( "SELECT * FROM documents WHERE id=? AND worker_id=?", ).get(docId, workerId) as { storage_name: string; @@ -1052,25 +1130,31 @@ app.get("/v1/workers/:id/documents/:docId", requireAuth, async (c) => { original_name: string; } | undefined; if (!doc) return c.json({ error: "Documento no encontrado" }, 404); - const enc = await Deno.readFile(join(workerDir(workerId), doc.storage_name)); + const enc = await getObject(workerDocKey(workerId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); - c.header("Content-Type", doc.mime); - c.header("Content-Disposition", `inline; filename="${doc.original_name}"`); + c.header("Content-Type", "application/octet-stream"); + c.header("X-Content-Type-Options", "nosniff"); + c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); return c.body(plain.buffer as ArrayBuffer); }); -app.get("/v1/workers/:id/photo", requireAuth, async (c) => { +app.get("/v1/workers/:id/photo", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); + const db = c.get("db"); const bytes = await loadCurrentPhoto(db, id); if (!bytes) return c.json({ error: "Sin foto" }, 404); const jpeg = bytes[0] === 0xff && bytes[1] === 0xd8; + // La foto sí se sirve inline (es una imagen legítima usada en la UI para + // mostrar el rostro de la persona), pero se fija el Content-Type real + // detectado por firma de bytes, no el que declaró quien la subió, y se + // agrega nosniff -- así el navegador no puede reinterpretarla como HTML. c.header("Content-Type", jpeg ? "image/jpeg" : "image/png"); + c.header("X-Content-Type-Options", "nosniff"); c.header("Cache-Control", "private, max-age=30"); return c.body(bytes.buffer as ArrayBuffer); }); -app.get("/v1/badge-qr", requireAuth, async (c) => { +app.get("/v1/badge-qr", ...requireCoreAuth, async (c) => { const curp = (c.req.query("curp") ?? "").trim().toUpperCase(); if (curp.length < 10) return c.json({ error: "CURP requerida" }, 400); const png = await badgeQrPng(curp); @@ -1079,90 +1163,104 @@ app.get("/v1/badge-qr", requireAuth, async (c) => { return c.body(png.buffer as ArrayBuffer); }); -app.post("/v1/projects/:id/badge-jobs", requireAuth, async (c) => { +app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => { const projectId = Number(c.req.param("id")); const body = await c.req.json<{ worker_ids?: number[] }>().catch(() => ({ worker_ids: [] as number[] })); - const db = await getDb(); + const db = c.get("db"); const blocked = projectMustBe( - projectById(db, projectId), + await projectById(db, projectId), ["activo"], "Solo se generan gafetes de proyectos activos", ); if (blocked) return c.json({ error: blocked.error }, blocked.status); let ids = body.worker_ids ?? []; if (!ids.length) { - ids = (db.prepare( + ids = (await db.prepare( `SELECT w.id FROM workers w - JOIN assignments a ON a.worker_id = w.id AND a.project_id = ? AND a.active = 1 - WHERE w.status = 'activo' AND w.needs_badge = 1 - AND EXISTS (SELECT 1 FROM documents d WHERE d.worker_id=w.id AND d.type_code='foto' AND d.is_current=1)`, + JOIN assignments a ON a.worker_id = w.id AND a.project_id = ? AND a.active = true + WHERE w.status = 'activo' AND w.needs_badge = true + AND EXISTS (SELECT 1 FROM documents d WHERE d.worker_id=w.id AND d.type_code='foto' AND d.is_current=true)`, ).all(projectId) as { id: number }[]).map((r) => r.id); } if (!ids.length) return c.json({ error: "No hay personal activo con foto para imprimir" }, 400); const bytes = await generateBadgePdf(db, projectId, ids); - db.prepare( - "INSERT INTO badge_jobs (project_id, status, created_by) VALUES (?, 'done', ?)", - ).run(projectId, c.get("user").id); - const jobId = lastInsertId(db); + const user = c.get("user"); + await db.prepare( + "INSERT INTO badge_jobs (project_id, status, created_by_id, created_by_name) VALUES (?, 'done', ?, ?)", + ).run(projectId, user.id, user.display_name); + const jobId = await lastInsertId(db); const path = await saveJobPdf(bytes, jobId); - db.prepare("UPDATE badge_jobs SET pdf_path=? WHERE id=?").run(path, jobId); + await db.prepare("UPDATE badge_jobs SET pdf_path=? WHERE id=?").run(path, jobId); const ins = db.prepare("INSERT INTO badge_job_people (job_id, worker_id) VALUES (?, ?)"); + const tid = tenantScope(user); for (const wid of ids) { - ins.run(jobId, wid); - refreshPipeline(db, wid); + await ins.run(jobId, wid); + await refreshPipeline(db, wid, tid); } return c.json({ id: jobId, count: ids.length }); }); -app.get("/v1/badge-jobs", requireAuth, async (c) => { - const db = await getDb(); - const jobs = db.prepare( +app.get("/v1/badge-jobs", ...requireCoreAuth, async (c) => { + const db = c.get("db"); + const jobs = await db.prepare( `SELECT j.*, p.name AS project_name, (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id) AS people, - (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id AND x.delivered=1) AS delivered + (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id AND x.delivered=true) AS delivered FROM badge_jobs j JOIN projects p ON p.id=j.project_id ORDER BY j.id DESC`, ).all(); return c.json({ jobs }); }); -app.get("/v1/badge-jobs/:id", requireAuth, async (c) => { +app.get("/v1/badge-jobs/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const job = db.prepare("SELECT * FROM badge_jobs WHERE id=?").get(id); + const db = c.get("db"); + const job = await db.prepare("SELECT * FROM badge_jobs WHERE id=?").get(id); if (!job) return c.json({ error: "Job no encontrado" }, 404); - const people = db.prepare( + const people = await db.prepare( `SELECT p.*, w.first_name, w.last_name_p, w.position FROM badge_job_people p JOIN workers w ON w.id = p.worker_id WHERE p.job_id=?`, ).all(id); return c.json({ job, people }); }); -app.get("/v1/badge-jobs/:id/pdf", requireAuth, async (c) => { +app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const job = db.prepare("SELECT pdf_path FROM badge_jobs WHERE id=?").get(id) as { pdf_path: string } | undefined; + const db = c.get("db"); + const job = await db.prepare("SELECT pdf_path FROM badge_jobs WHERE id=?").get(id) as { pdf_path: string } | undefined; if (!job?.pdf_path) return c.json({ error: "PDF no encontrado" }, 404); - const bytes = await Deno.readFile(job.pdf_path); + const bytes = await getObject(job.pdf_path); c.header("Content-Type", "application/pdf"); c.header("Content-Disposition", `attachment; filename="gafetes-${id}.pdf"`); return c.body(bytes.buffer as ArrayBuffer); }); -app.patch("/v1/badge-jobs/:id/people/:workerId", requireAuth, async (c) => { +app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) => { const jobId = Number(c.req.param("id")); const workerId = Number(c.req.param("workerId")); const { delivered } = await c.req.json<{ delivered: boolean }>(); - const db = await getDb(); - db.prepare( - `UPDATE badge_job_people SET delivered=?, delivered_at=CASE WHEN ? THEN datetime('now') ELSE NULL END + const db = c.get("db"); + await db.prepare( + `UPDATE badge_job_people SET delivered=?, delivered_at=CASE WHEN ? THEN now() ELSE NULL END WHERE job_id=? AND worker_id=?`, - ).run(delivered ? 1 : 0, delivered ? 1 : 0, jobId, workerId); + ).run(delivered, delivered, jobId, workerId); return c.json({ ok: true }); }); registerPayrollRoutes(app); const port = config.port; -await getDb(); + +// Fail-fast (Fase 7): antes con SQLite la app "siempre arrancaba" (creaba +// el archivo si no existía). Con Postgres/Redis, si alguna de las 5 +// conexiones no responde al arrancar, es mejor fallar ruidosamente que +// dejar que el primer request autenticado descubra el problema. +await Promise.all([ + pingCoreDb(), + pingPlatformDb(), + pingRedis().then((r) => { + if (!r.iam || !r.core) throw new Error("Redis (iam/core) no responde"); + }), +]); + Deno.serve({ port, hostname: "0.0.0.0" }, app.fetch); console.log(`API panel-obra en http://127.0.0.1:${port}`); diff --git a/api/mx.ts b/api/mx.ts index 4a7c030..9b8a110 100644 --- a/api/mx.ts +++ b/api/mx.ts @@ -174,7 +174,7 @@ export function normalizeWorker(body: WorkerInput) { risk_code: canonicalRiskCode(body.risk_code), work_type: (body.work_type ?? "").toUpperCase(), daily_wage: Number(body.daily_wage ?? 0), - needs_badge: body.needs_badge === false || body.needs_badge === 0 ? 0 : 1, + needs_badge: !(body.needs_badge === false || body.needs_badge === 0), status: body.status === "baja" ? "baja" : "activo", }; } diff --git a/api/payroll.ts b/api/payroll.ts index 6023931..62f34d6 100644 --- a/api/payroll.ts +++ b/api/payroll.ts @@ -1,6 +1,6 @@ -import type { Database } from "@db/sqlite"; +import type { Db } from "./db.ts"; +import { tenantTimezone, todayInTimezone } from "./db.ts"; -export const PAYROLL_TZ = "America/Cancun"; export const DEFAULT_UNITS = [ { code: "m", label: "Metro (m)" }, { code: "m2", label: "Metro cuadrado (m²)" }, @@ -13,7 +13,7 @@ export type WeekStatus = "draft" | "assembled" | "paid"; export type PayrollSettings = { tenant_id: number; - loan_commission_enabled: number; + loan_commission_enabled: boolean; loan_commission_pct: number; loan_small_max: number; }; @@ -33,10 +33,6 @@ type LoanRow = { created_at: string; }; -function lid(db: Database): number { - return Number((db.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); -} - export function roundMoney(n: number): number { return Math.round((Number(n) || 0) * 100) / 100; } @@ -45,8 +41,19 @@ export function commissionAmount(delivered: number, pct: number): number { return roundMoney(delivered * (Number(pct) || 0) / 100); } -export function todayIso(now = new Date()): string { - return now.toLocaleDateString("en-CA", { timeZone: PAYROLL_TZ }); +/** "Hoy" en una zona horaria dada (Fase 4d: por tenant, ya no PAYROLL_TZ + * hardcodeado). Se mantiene aquí como alias delgado sobre + * db.ts#todayInTimezone para no duplicar la lógica. */ +export function todayIso(timezone: string, now = new Date()): string { + return todayInTimezone(timezone, now); +} + +/** Resuelve "hoy" para un tenant consultando su configuración. Punto de + * entrada único para los endpoints que antes usaban `todayIso()` sin + * argumentos (implícitamente America/Cancun para todos los tenants). */ +export async function resolveToday(db: Db, tenantId: number): Promise { + const tz = await tenantTimezone(db, tenantId); + return todayInTimezone(tz); } export function addDays(iso: string, days: number): string { @@ -87,7 +94,7 @@ export function weekAssembleDate(weekStart: string): string { return addDays(weekStart, 3); } -export function canAssembleWeek(weekStart: string, today = todayIso()): boolean { +export function canAssembleWeek(weekStart: string, today: string): boolean { return today >= weekAssembleDate(weekStart); } @@ -110,60 +117,59 @@ export function loanConditionLabel( return `${moneyLabel(payAmount)} · ${installmentN}/${loan.installments_n || 1} · ${comm}`; } -export function getSettings(db: Database, tenantId: number): PayrollSettings { - seedDefaults(db, tenantId); - const row = db.prepare("SELECT * FROM payroll_settings WHERE tenant_id=?").get(tenantId) as PayrollSettings; - return row; +export async function getSettings(db: Db, tenantId: number): Promise { + await seedDefaults(db, tenantId); + return await db.prepare("SELECT * FROM payroll_settings WHERE tenant_id=?").get(tenantId) as PayrollSettings; } -export function saveSettings( - db: Database, +export async function saveSettings( + db: Db, tenantId: number, patch: Partial, -): PayrollSettings { - const cur = getSettings(db, tenantId); +): Promise { + const cur = await getSettings(db, tenantId); const next = { loan_commission_enabled: patch.loan_commission_enabled ?? cur.loan_commission_enabled, loan_commission_pct: patch.loan_commission_pct ?? cur.loan_commission_pct, loan_small_max: patch.loan_small_max ?? cur.loan_small_max, }; - db.prepare( + await db.prepare( `UPDATE payroll_settings SET loan_commission_enabled=?, loan_commission_pct=?, loan_small_max=? WHERE tenant_id=?`, ).run( - next.loan_commission_enabled ? 1 : 0, + Boolean(next.loan_commission_enabled), Number(next.loan_commission_pct) || 0, Number(next.loan_small_max) || 0, tenantId, ); - return getSettings(db, tenantId); + return await getSettings(db, tenantId); } -export function seedDefaults(db: Database, tenantId: number) { - db.prepare( +export async function seedDefaults(db: Db, tenantId: number): Promise { + await db.prepare( `INSERT INTO payroll_settings (tenant_id, loan_commission_enabled, loan_commission_pct, loan_small_max) - VALUES (?, 1, 10, 500) + VALUES (?, true, 10, 500) ON CONFLICT(tenant_id) DO NOTHING`, ).run(tenantId); - const n = db.prepare("SELECT COUNT(*) AS n FROM destajo_units WHERE tenant_id=?").get(tenantId) as { n: number }; - if (n.n === 0) { + const n = await db.prepare("SELECT COUNT(*) AS n FROM destajo_units WHERE tenant_id=?").get(tenantId) as { n: number }; + if (Number(n.n) === 0) { const ins = db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)"); - for (const u of DEFAULT_UNITS) ins.run(tenantId, u.code, u.label); + for (const u of DEFAULT_UNITS) await ins.run(tenantId, u.code, u.label); } } -export function listUnits(db: Database, tenantId: number) { - seedDefaults(db, tenantId); - return db.prepare("SELECT * FROM destajo_units WHERE tenant_id=? ORDER BY id").all(tenantId); +export async function listUnits(db: Db, tenantId: number) { + await seedDefaults(db, tenantId); + return await db.prepare("SELECT * FROM destajo_units WHERE tenant_id=? ORDER BY id").all(tenantId); } -export function addUnit(db: Database, tenantId: number, code: string, label: string) { +export async function addUnit(db: Db, tenantId: number, code: string, label: string): Promise { const c = code.trim().toLowerCase().replace(/\s+/g, ""); const l = label.trim() || c; if (!c) throw new Error("Unidad requerida"); - db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)").run(tenantId, c, l); - return lid(db); + await db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)").run(tenantId, c, l); + return await db.lastInsertId(); } export function firstDueForLoan(opts: { @@ -179,8 +185,8 @@ export function firstDueForLoan(opts: { return thisSaturday(opts.grantIso); } -export function createLoan( - db: Database, +export async function createLoan( + db: Db, tenantId: number, body: { worker_id: number; @@ -192,10 +198,10 @@ export function createLoan( note?: string; grantIso?: string; }, -): { id: number } { - const worker = db.prepare("SELECT id FROM workers WHERE id=?").get(body.worker_id); +): Promise<{ id: number }> { + const worker = await db.prepare("SELECT id FROM workers WHERE id=?").get(body.worker_id); if (!worker) throw Object.assign(new Error("Persona no encontrada"), { status: 404 }); - const settings = getSettings(db, tenantId); + const settings = await getSettings(db, tenantId); const delivered = roundMoney(body.delivered); if (delivered <= 0) throw Object.assign(new Error("Monto requerido"), { status: 400 }); const enabled = body.commission_enabled ?? Boolean(settings.loan_commission_enabled); @@ -212,9 +218,9 @@ export function createLoan( if (plan === "installments" && installments < 2) installments = 2; if (plan === "single") installments = 1; const weekly = roundMoney(balance / installments); - const grantIso = body.grantIso ?? todayIso(); + const grantIso = body.grantIso ?? await resolveToday(db, tenantId); const week = weekContaining(grantIso); - const current = db.prepare( + const current = await db.prepare( "SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?", ).get(tenantId, week.weekStart) as { id: number; status: string } | undefined; const assembled = current?.status === "assembled" || current?.status === "paid"; @@ -225,7 +231,7 @@ export function createLoan( grantIso, weekAssembled: assembled && weekdayMon0(grantIso) >= 4, }); - db.prepare( + await db.prepare( `INSERT INTO loans (worker_id, amount, delivered, balance, weekly_payment, note, commission_pct, commission_amount, plan, installments_n, first_due) @@ -243,21 +249,21 @@ export function createLoan( installments, firstDue, ); - const id = lid(db); - if (current && current.status !== "paid") recalcWeek(db, current.id); + const id = await db.lastInsertId(); + if (current && current.status !== "paid") await recalcWeek(db, current.id); return { id }; } -export function presentOnOtherProject( - db: Database, +export async function presentOnOtherProject( + db: Db, workerId: number, workDate: string, exceptProjectId?: number, -): { project_id: number; project_name: string } | null { - const row = db.prepare( +): Promise<{ project_id: number; project_name: string } | null> { + const row = await db.prepare( `SELECT a.project_id, p.name AS project_name FROM attendance a JOIN projects p ON p.id=a.project_id - WHERE a.worker_id=? AND a.work_date=? AND a.present=1 + WHERE a.worker_id=? AND a.work_date=? AND a.present=true AND a.project_id != ?`, ).get(workerId, workDate, exceptProjectId ?? 0) as | { project_id: number; project_name: string } @@ -265,13 +271,13 @@ export function presentOnOtherProject( return row ?? null; } -export function setAttendance( - db: Database, +export async function setAttendance( + db: Db, tenantId: number, body: { project_id: number; worker_id: number; work_date: string; present: boolean }, -): { ok: true } | { error: string; status: number; other?: { project_id: number; project_name: string } } { +): Promise<{ ok: true } | { error: string; status: number; other?: { project_id: number; project_name: string } }> { if (body.present) { - const other = presentOnOtherProject(db, body.worker_id, body.work_date, body.project_id); + const other = await presentOnOtherProject(db, body.worker_id, body.work_date, body.project_id); if (other) { return { error: `Ese día ya está marcado en ${other.project_name}`, @@ -280,45 +286,45 @@ export function setAttendance( }; } } - db.prepare( + await db.prepare( `INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, ?) ON CONFLICT(worker_id, project_id, work_date) DO UPDATE SET present=excluded.present`, - ).run(body.worker_id, body.project_id, body.work_date, body.present ? 1 : 0); + ).run(body.worker_id, body.project_id, body.work_date, body.present); const { weekStart } = weekContaining(body.work_date); - const week = db.prepare( + const week = await db.prepare( "SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?", ).get(tenantId, weekStart) as { id: number; status: string } | undefined; - if (week && week.status !== "paid") recalcWeek(db, week.id); + if (week && week.status !== "paid") await recalcWeek(db, week.id); return { ok: true }; } -export function ensureWeek(db: Database, tenantId: number, weekStart: string): number { - seedDefaults(db, tenantId); +export async function ensureWeek(db: Db, tenantId: number, weekStart: string): Promise { + await seedDefaults(db, tenantId); const { weekEnd } = weekContaining(weekStart); const start = weekContaining(weekStart).weekStart; - let week = db.prepare( + let week = await db.prepare( "SELECT id FROM payroll_weeks WHERE tenant_id=? AND week_start=?", ).get(tenantId, start) as { id: number } | undefined; if (!week) { - db.prepare( + await db.prepare( `INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id) VALUES (?, ?, 'draft', ?)`, ).run(start, weekEnd, tenantId); - week = { id: lid(db) }; + week = { id: await db.lastInsertId() }; } const weekId = week.id; const destajo = destajoPeriodBounds(start); - const period = db.prepare( + const period = await db.prepare( "SELECT id FROM destajo_periods WHERE tenant_id=? AND period_end=?", ).get(tenantId, destajo.periodEnd) as { id: number } | undefined; if (!period) { - db.prepare( + await db.prepare( `INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id) VALUES (?, ?, ?, ?)`, ).run(destajo.periodStart, destajo.periodEnd, weekId, tenantId); } - const projects = db.prepare( + const projects = await db.prepare( `SELECT id FROM projects WHERE status IN ('activo', 'pausado') AND (? = 0 OR tenant_id = ? OR tenant_id IS NULL) @@ -326,32 +332,32 @@ export function ensureWeek(db: Database, tenantId: number, weekStart: string): n ).all(tenantId, tenantId) as { id: number }[]; const hasSheet = db.prepare( `SELECT id FROM payroll_sheets - WHERE week_id=? AND kind=? AND IFNULL(project_id, 0)=IFNULL(?, 0)`, + WHERE week_id=? AND kind=? AND COALESCE(project_id, 0)=COALESCE(?, 0)`, ); const insSheet = db.prepare( `INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (?, ?, ?)`, ); - const ensureSheet = (kind: string, projectId: number | null) => { - if (hasSheet.get(weekId, kind, projectId)) return; - insSheet.run(weekId, kind, projectId); + const ensureSheet = async (kind: string, projectId: number | null) => { + if (await hasSheet.get(weekId, kind, projectId)) return; + await insSheet.run(weekId, kind, projectId); }; for (const p of projects) { - ensureSheet("obra", p.id); - ensureSheet("destajo", p.id); + await ensureSheet("obra", p.id); + await ensureSheet("destajo", p.id); } - ensureSheet("admin", null); + await ensureSheet("admin", null); return weekId; } -function jornalRoster(db: Database, projectId: number, weekStart: string, weekEnd: string, weekId = 0) { - return db.prepare( +async function jornalRoster(db: Db, projectId: number, weekStart: string, weekEnd: string, weekId = 0) { + return await db.prepare( `SELECT w.id, w.first_name, w.last_name_p, w.position, w.daily_wage, w.work_type FROM workers w WHERE w.status='activo' AND w.pipeline_status != 'baja' AND ( EXISTS ( SELECT 1 FROM assignments a - WHERE a.worker_id=w.id AND a.project_id=? AND a.active=1 AND w.work_type='N' + WHERE a.worker_id=w.id AND a.project_id=? AND a.active=true AND w.work_type='N' ) OR EXISTS ( SELECT 1 FROM attendance att @@ -374,40 +380,40 @@ function jornalRoster(db: Database, projectId: number, weekStart: string, weekEn }[]; } -function sheetLine( - db: Database, +async function sheetLine( + db: Db, sheetId: number, workerId: number, destajoCutLineId: number | null, -): number { - const existing = db.prepare( +): Promise { + const existing = await db.prepare( destajoCutLineId ? "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND destajo_cut_line_id=?" : "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=? AND destajo_cut_line_id IS NULL", ).get(sheetId, destajoCutLineId ?? workerId) as { id: number } | undefined; if (existing) return existing.id; - db.prepare( + await db.prepare( `INSERT INTO payroll_week_lines (sheet_id, worker_id, destajo_cut_line_id) VALUES (?, ?, ?)`, ).run(sheetId, workerId, destajoCutLineId); - return lid(db); + return await db.lastInsertId(); } -function dueLoansForSaturday(db: Database, workerId: number, saturday: string): LoanRow[] { - return db.prepare( +async function dueLoansForSaturday(db: Db, workerId: number, saturday: string): Promise { + return await db.prepare( `SELECT * FROM loans WHERE worker_id=? AND balance > 0 AND first_due IS NOT NULL AND first_due <= ? ORDER BY first_due, id`, ).all(workerId, saturday) as LoanRow[]; } -function paidInstallments(db: Database, loanId: number): number { - const row = db.prepare("SELECT COUNT(*) AS n FROM loan_payments WHERE loan_id=?").get(loanId) as { n: number }; - return row.n; +async function paidInstallments(db: Db, loanId: number): Promise { + const row = await db.prepare("SELECT COUNT(*) AS n FROM loan_payments WHERE loan_id=?").get(loanId) as { n: number }; + return Number(row.n); } -export function recalcWeek(db: Database, weekId: number) { - const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { +export async function recalcWeek(db: Db, weekId: number): Promise { + const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { id: number; week_start: string; week_end: string; @@ -416,43 +422,42 @@ export function recalcWeek(db: Database, weekId: number) { tenant_id: number; } | undefined; if (!week || week.status === "paid") return; - const days = weekDays(week.week_start); const saturday = week.week_end; - const sheets = db.prepare("SELECT * FROM payroll_sheets WHERE week_id=?").all(weekId) as { + const sheets = await db.prepare("SELECT * FROM payroll_sheets WHERE week_id=?").all(weekId) as { id: number; kind: string; project_id: number | null; }[]; - db.prepare("UPDATE payroll_week_lines SET loan_id=NULL, loan_discount=0, loan_label=NULL WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)") + await db.prepare("UPDATE payroll_week_lines SET loan_id=NULL, loan_discount=0, loan_label=NULL WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)") .run(weekId); for (const sheet of sheets) { if (sheet.kind === "obra" && sheet.project_id) { - const roster = jornalRoster(db, sheet.project_id, week.week_start, week.week_end, weekId); + const roster = await jornalRoster(db, sheet.project_id, week.week_start, week.week_end, weekId); for (const w of roster) { - const lineId = sheetLine(db, sheet.id, w.id, null); - const att = db.prepare( + const lineId = await sheetLine(db, sheet.id, w.id, null); + const att = await db.prepare( `SELECT COUNT(*) AS n FROM attendance - WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=1`, + WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`, ).get(w.id, sheet.project_id, week.week_start, week.week_end) as { n: number }; - const frozen = db.prepare("SELECT daily_wage FROM payroll_week_lines WHERE id=?").get(lineId) as { daily_wage: number }; + const frozen = await db.prepare("SELECT daily_wage FROM payroll_week_lines WHERE id=?").get(lineId) as { daily_wage: number }; const wage = week.status === "assembled" && frozen.daily_wage > 0 ? frozen.daily_wage : w.daily_wage; - const gross = roundMoney(att.n * wage); - db.prepare( + const gross = roundMoney(Number(att.n) * wage); + await db.prepare( `UPDATE payroll_week_lines SET days=?, daily_wage=?, amount=?, gross=?, payable_net=? WHERE id=?`, - ).run(att.n, wage, gross, gross, gross, lineId); + ).run(Number(att.n), wage, gross, gross, gross, lineId); } } if (sheet.kind === "destajo" && sheet.project_id) { - const period = db.prepare( + const period = await db.prepare( "SELECT id FROM destajo_periods WHERE week_id=?", ).get(weekId) as { id: number } | undefined; if (!period) continue; - const cuts = db.prepare( + const cuts = await db.prepare( `SELECT c.*, j.worker_id, j.unit_price, j.unit_code, j.concepto FROM destajo_cut_lines c JOIN destajo_jobs j ON j.id=c.job_id @@ -469,7 +474,7 @@ export function recalcWeek(db: Database, weekId: number) { }[]; const keep = new Set(); for (const cut of cuts) { - const lineId = sheetLine(db, sheet.id, cut.worker_id, cut.id); + const lineId = await sheetLine(db, sheet.id, cut.worker_id, cut.id); keep.add(lineId); const payableQty = roundMoney( week.status === "draft" && cut.qty_actual === 0 && cut.qty_extra === 0 @@ -478,7 +483,7 @@ export function recalcWeek(db: Database, weekId: number) { ); const gross = roundMoney(payableQty * cut.unit_price); const requiredGross = roundMoney(cut.qty_planned * cut.unit_price); - db.prepare( + await db.prepare( `UPDATE payroll_week_lines SET destajo_cut_line_id=?, qty_planned=?, qty_actual=?, qty_extra=?, unit_price=?, unit_code=?, concepto=?, amount=?, gross=?, payable_net=? @@ -497,23 +502,23 @@ export function recalcWeek(db: Database, weekId: number) { lineId, ); } - const stale = db.prepare( + const stale = await db.prepare( "SELECT id, destajo_cut_line_id FROM payroll_week_lines WHERE sheet_id=?", ).all(sheet.id) as { id: number; destajo_cut_line_id: number | null }[]; for (const row of stale) { - if (!keep.has(row.id)) db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(row.id); + if (!keep.has(row.id)) await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(row.id); } } if (sheet.kind === "admin") { - const lines = db.prepare("SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id=?").all(sheet.id) as { + const lines = await db.prepare("SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id=?").all(sheet.id) as { id: number; amount: number; discounts: number; }[]; for (const line of lines) { const gross = roundMoney(line.amount); - db.prepare("UPDATE payroll_week_lines SET gross=?, payable_net=? WHERE id=?").run( + await db.prepare("UPDATE payroll_week_lines SET gross=?, payable_net=? WHERE id=?").run( gross, roundMoney(gross - line.discounts), line.id, @@ -522,7 +527,7 @@ export function recalcWeek(db: Database, weekId: number) { } } - const allLines = db.prepare( + const allLines = await db.prepare( `SELECT l.id, l.worker_id, l.gross, l.discounts, l.payable_net FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id @@ -539,12 +544,12 @@ export function recalcWeek(db: Database, weekId: number) { for (const [workerId, lines] of byWorker) { const gross = roundMoney(lines.reduce((s, l) => s + l.gross - l.discounts, 0)); - const loans = dueLoansForSaturday(db, workerId, saturday); + const loans = await dueLoansForSaturday(db, workerId, saturday); let remainingNet = Math.max(0, gross); let placed = false; for (const loan of loans) { if (remainingNet <= 0) break; - const n = paidInstallments(db, loan.id) + 1; + const n = await paidInstallments(db, loan.id) + 1; const isLast = n >= (loan.installments_n || 1); const want = isLast ? loan.balance : Math.min(loan.weekly_payment || loan.balance, loan.balance); const take = roundMoney(Math.min(want, remainingNet)); @@ -553,7 +558,7 @@ export function recalcWeek(db: Database, weekId: number) { if (!placed) { const host = lines[0]; const label = loanConditionLabel(loan, take, n); - db.prepare( + await db.prepare( `UPDATE payroll_week_lines SET loan_id=?, loan_discount=?, loan_label=?, payable_net=? WHERE id=?`, ).run(loan.id, take, label, roundMoney(host.gross - host.discounts - take), host.id); placed = true; @@ -562,14 +567,14 @@ export function recalcWeek(db: Database, weekId: number) { } if (!placed) { for (const line of lines) { - db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run( + await db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run( roundMoney(line.gross - line.discounts), line.id, ); } } else { for (const line of lines.slice(1)) { - db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run( + await db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run( roundMoney(line.gross - line.discounts), line.id, ); @@ -577,37 +582,37 @@ export function recalcWeek(db: Database, weekId: number) { } } - const tot = db.prepare( + const tot = await db.prepare( `SELECT COALESCE(SUM(l.payable_net), 0) AS n FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id WHERE s.week_id=?`, ).get(weekId) as { n: number }; - db.prepare("UPDATE payroll_weeks SET payable_net=? WHERE id=?").run(roundMoney(tot.n), weekId); + await db.prepare("UPDATE payroll_weeks SET payable_net=? WHERE id=?").run(roundMoney(tot.n), weekId); if (week.status === "draft") { - db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(roundMoney(tot.n), weekId); - db.prepare( + await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(roundMoney(tot.n), weekId); + await db.prepare( `UPDATE payroll_week_lines SET required_net=payable_net WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`, ).run(weekId); } } -function prefillThuSat(db: Database, projectId: number, weekStart: string, workerId: number) { +async function prefillThuSat(db: Db, projectId: number, weekStart: string, workerId: number): Promise { for (const offset of [3, 4, 5]) { const day = addDays(weekStart, offset); - if (presentOnOtherProject(db, workerId, day, projectId)) continue; - const existing = db.prepare( + if (await presentOnOtherProject(db, workerId, day, projectId)) continue; + const existing = await db.prepare( "SELECT id, present FROM attendance WHERE worker_id=? AND project_id=? AND work_date=?", - ).get(workerId, projectId, day) as { id: number; present: number } | undefined; + ).get(workerId, projectId, day) as { id: number; present: boolean } | undefined; if (existing) continue; - db.prepare( - "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, 1)", + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, true)", ).run(workerId, projectId, day); } } -export function assembleWeek(db: Database, weekId: number, nowIso = todayIso()) { - const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { +export async function assembleWeek(db: Db, weekId: number, nowIso: string): Promise { + const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { id: number; week_start: string; status: string; @@ -621,47 +626,47 @@ export function assembleWeek(db: Database, weekId: number, nowIso = todayIso()) { status: 400 }, ); } - const sheets = db.prepare( + const sheets = await db.prepare( "SELECT * FROM payroll_sheets WHERE week_id=? AND kind='obra'", ).all(weekId) as { id: number; project_id: number | null }[]; for (const sheet of sheets) { if (!sheet.project_id) continue; - const roster = jornalRoster(db, sheet.project_id, week.week_start, addDays(week.week_start, 5), weekId); + const roster = await jornalRoster(db, sheet.project_id, week.week_start, addDays(week.week_start, 5), weekId); for (const w of roster) { - const assigned = db.prepare( - "SELECT 1 AS ok FROM assignments WHERE worker_id=? AND project_id=? AND active=1", + const assigned = await db.prepare( + "SELECT 1 AS ok FROM assignments WHERE worker_id=? AND project_id=? AND active=true", ).get(w.id, sheet.project_id) as { ok: number } | undefined; if (!assigned) continue; if (w.work_type !== "N") { - const hasLine = db.prepare( + const hasLine = await db.prepare( "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=?", ).get(sheet.id, w.id); if (!hasLine) continue; } - prefillThuSat(db, sheet.project_id, week.week_start, w.id); + await prefillThuSat(db, sheet.project_id, week.week_start, w.id); } } - const period = db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined; + const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined; if (period) { - db.prepare( + await db.prepare( `UPDATE destajo_cut_lines SET qty_actual = qty_planned WHERE period_id=? AND qty_actual=0 AND qty_planned>0`, ).run(period.id); } - db.prepare( - "UPDATE payroll_weeks SET status='assembled', assembled_at=datetime('now') WHERE id=?", + await db.prepare( + "UPDATE payroll_weeks SET status='assembled', assembled_at=now() WHERE id=?", ).run(weekId); - recalcWeek(db, weekId); - const payable = db.prepare("SELECT payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { payable_net: number }; - db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(payable.payable_net, weekId); - db.prepare( + await recalcWeek(db, weekId); + const payable = await db.prepare("SELECT payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { payable_net: number }; + await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(payable.payable_net, weekId); + await db.prepare( `UPDATE payroll_week_lines SET required_net=payable_net WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`, ).run(weekId); } -export function payWeek(db: Database, weekId: number) { - const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { +export async function payWeek(db: Db, weekId: number, nowIso: string): Promise { + const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { id: number; week_end: string; status: string; @@ -669,114 +674,114 @@ export function payWeek(db: Database, weekId: number) { if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 }); if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 }); if (week.status !== "assembled") { - assembleWeek(db, weekId); + await assembleWeek(db, weekId, nowIso); } - recalcWeek(db, weekId); - const lines = db.prepare( + await recalcWeek(db, weekId); + const lines = await db.prepare( `SELECT l.loan_id, l.loan_discount, l.loan_label, l.worker_id FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id WHERE s.week_id=? AND l.loan_id IS NOT NULL AND l.loan_discount > 0`, ).all(weekId) as { loan_id: number; loan_discount: number; loan_label: string; worker_id: number }[]; for (const line of lines) { - const loan = db.prepare("SELECT * FROM loans WHERE id=?").get(line.loan_id) as LoanRow | undefined; + const loan = await db.prepare("SELECT * FROM loans WHERE id=?").get(line.loan_id) as LoanRow | undefined; if (!loan || loan.balance <= 0) continue; const take = roundMoney(Math.min(line.loan_discount, loan.balance)); if (take <= 0) continue; - const n = paidInstallments(db, loan.id) + 1; - db.prepare( + const n = await paidInstallments(db, loan.id) + 1; + await db.prepare( `INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label) VALUES (?, ?, ?, ?, ?)`, ).run(loan.id, weekId, take, n, line.loan_label); - db.prepare("UPDATE loans SET balance = ROUND(balance - ?, 2) WHERE id=?").run(take, loan.id); + await db.prepare("UPDATE loans SET balance = ROUND((balance - ?)::numeric, 2) WHERE id=?").run(take, loan.id); } - const period = db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined; + const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined; if (period) { - const jobs = db.prepare("SELECT id, qty_total_estimated FROM destajo_jobs").all() as { + const jobs = await db.prepare("SELECT id, qty_total_estimated FROM destajo_jobs").all() as { id: number; qty_total_estimated: number; }[]; for (const job of jobs) { - const paid = db.prepare( + const paid = await db.prepare( `SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n FROM destajo_cut_lines c JOIN destajo_periods p ON p.id=c.period_id JOIN payroll_weeks w ON w.id=p.week_id WHERE c.job_id=? AND (w.status='paid' OR w.id=?)`, ).get(job.id, weekId) as { n: number }; - if (paid.n >= job.qty_total_estimated) { - db.prepare("UPDATE destajo_jobs SET status='done' WHERE id=?").run(job.id); + if (Number(paid.n) >= job.qty_total_estimated) { + await db.prepare("UPDATE destajo_jobs SET status='done' WHERE id=?").run(job.id); } } } - db.prepare( - "UPDATE payroll_weeks SET status='paid', paid_at=datetime('now') WHERE id=?", + await db.prepare( + "UPDATE payroll_weeks SET status='paid', paid_at=now() WHERE id=?", ).run(weekId); } -export function addJornalWorker(db: Database, weekId: number, projectId: number, workerId: number) { - const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; +export async function addJornalWorker(db: Db, weekId: number, projectId: number, workerId: number): Promise { + const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - const sheet = db.prepare( + const sheet = await db.prepare( "SELECT id FROM payroll_sheets WHERE week_id=? AND kind='obra' AND project_id=?", ).get(weekId, projectId) as { id: number } | undefined; if (!sheet) throw Object.assign(new Error("Hoja de obra no encontrada"), { status: 404 }); - sheetLine(db, sheet.id, workerId, null); - recalcWeek(db, weekId); + await sheetLine(db, sheet.id, workerId, null); + await recalcWeek(db, weekId); } -export function addAdminLine( - db: Database, +export async function addAdminLine( + db: Db, weekId: number, body: { worker_id: number; amount: number; project_id?: number | null }, -) { - const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; +): Promise { + const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - const sheet = db.prepare( + const sheet = await db.prepare( "SELECT id FROM payroll_sheets WHERE week_id=? AND kind='admin' AND project_id IS NULL", ).get(weekId) as { id: number } | undefined; if (!sheet) throw Object.assign(new Error("Hoja administrativa no encontrada"), { status: 404 }); - const lineId = sheetLine(db, sheet.id, body.worker_id, null); - db.prepare("UPDATE payroll_week_lines SET amount=?, project_id=?, gross=?, payable_net=? WHERE id=?").run( + const lineId = await sheetLine(db, sheet.id, body.worker_id, null); + await db.prepare("UPDATE payroll_week_lines SET amount=?, project_id=?, gross=?, payable_net=? WHERE id=?").run( roundMoney(body.amount), body.project_id ?? null, roundMoney(body.amount), roundMoney(body.amount), lineId, ); - recalcWeek(db, weekId); + await recalcWeek(db, weekId); } -export function updateAdminLine(db: Database, weekId: number, lineId: number, amount: number) { - const week = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; +export async function updateAdminLine(db: Db, weekId: number, lineId: number, amount: number): Promise { + const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - db.prepare("UPDATE payroll_week_lines SET amount=? WHERE id=?").run(roundMoney(amount), lineId); - recalcWeek(db, weekId); + await db.prepare("UPDATE payroll_week_lines SET amount=? WHERE id=?").run(roundMoney(amount), lineId); + await recalcWeek(db, weekId); } -export function removeAdminLine(db: Database, weekId: number, lineId: number) { - const week = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; +export async function removeAdminLine(db: Db, weekId: number, lineId: number): Promise { + const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(lineId); - recalcWeek(db, weekId); + await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(lineId); + await recalcWeek(db, weekId); } -export function jobRemainder(db: Database, jobId: number): number { - const job = db.prepare("SELECT qty_total_estimated FROM destajo_jobs WHERE id=?").get(jobId) as +export async function jobRemainder(db: Db, jobId: number): Promise { + const job = await db.prepare("SELECT qty_total_estimated FROM destajo_jobs WHERE id=?").get(jobId) as | { qty_total_estimated: number } | undefined; if (!job) return 0; - const paid = db.prepare( + const paid = await db.prepare( `SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n FROM destajo_cut_lines c JOIN destajo_periods p ON p.id=c.period_id JOIN payroll_weeks w ON w.id=p.week_id WHERE c.job_id=? AND w.status='paid'`, ).get(jobId) as { n: number }; - return roundMoney(Math.max(0, job.qty_total_estimated - paid.n)); + return roundMoney(Math.max(0, job.qty_total_estimated - Number(paid.n))); } -export function createDestajoJob( - db: Database, +export async function createDestajoJob( + db: Db, tenantId: number, weekId: number, body: { @@ -789,12 +794,12 @@ export function createDestajoJob( qty_planned?: number; qty_extra?: number; }, -): { id: number } { - const week = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; +): Promise<{ id: number }> { + const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); const concepto = body.concepto.trim(); if (!concepto) throw Object.assign(new Error("Concepto requerido"), { status: 400 }); - db.prepare( + await db.prepare( `INSERT INTO destajo_jobs (project_id, worker_id, concepto, unit_code, qty_total_estimated, unit_price, tenant_id) VALUES (?, ?, ?, ?, ?, ?, ?)`, @@ -807,25 +812,25 @@ export function createDestajoJob( body.unit_price, tenantId, ); - const jobId = lid(db); - const period = db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number }; - db.prepare( + const jobId = await db.lastInsertId(); + const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number }; + await db.prepare( `INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra) VALUES (?, ?, ?, 0, ?)`, ).run(period.id, jobId, body.qty_planned ?? 0, body.qty_extra ?? 0); - recalcWeek(db, weekId); + await recalcWeek(db, weekId); return { id: jobId }; } -export function patchDestajoCut( - db: Database, +export async function patchDestajoCut( + db: Db, weekId: number, cutId: number, patch: { qty_planned?: number; qty_actual?: number; qty_extra?: number }, -) { - const week = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; +): Promise { + const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - const cut = db.prepare("SELECT * FROM destajo_cut_lines WHERE id=?").get(cutId) as { + const cut = await db.prepare("SELECT * FROM destajo_cut_lines WHERE id=?").get(cutId) as { qty_planned: number; qty_actual: number; qty_extra: number; @@ -834,24 +839,21 @@ export function patchDestajoCut( const planned = patch.qty_planned ?? cut.qty_planned; const actual = patch.qty_actual ?? cut.qty_actual; const extra = patch.qty_extra ?? cut.qty_extra; - if (week.status === "assembled" && patch.qty_planned != null) { - // required already frozen; planned can still be edited for display but does not rewrite required - } - db.prepare("UPDATE destajo_cut_lines SET qty_planned=?, qty_actual=?, qty_extra=? WHERE id=?").run( + await db.prepare("UPDATE destajo_cut_lines SET qty_planned=?, qty_actual=?, qty_extra=? WHERE id=?").run( planned, actual, extra, cutId, ); - recalcWeek(db, weekId); + await recalcWeek(db, weekId); } -export function ensureCutForOpenJobs(db: Database, weekId: number) { - const period = db.prepare("SELECT id, tenant_id FROM destajo_periods WHERE week_id=?").get(weekId) as +export async function ensureCutForOpenJobs(db: Db, weekId: number): Promise { + const period = await db.prepare("SELECT id, tenant_id FROM destajo_periods WHERE week_id=?").get(weekId) as | { id: number; tenant_id: number } | undefined; if (!period) return; - const jobs = db.prepare( + const jobs = await db.prepare( "SELECT id FROM destajo_jobs WHERE tenant_id=? AND status='open'", ).all(period.tenant_id) as { id: number }[]; const ins = db.prepare( @@ -859,7 +861,7 @@ export function ensureCutForOpenJobs(db: Database, weekId: number) { VALUES (?, ?, 0, 0, 0) ON CONFLICT(period_id, job_id) DO NOTHING`, ); - for (const job of jobs) ins.run(period.id, job.id); + for (const job of jobs) await ins.run(period.id, job.id); } type AttCell = { @@ -869,18 +871,18 @@ type AttCell = { other_project_name: string | null; }; -export function getWeekBundle(db: Database, tenantId: number, weekStart: string) { - const weekId = ensureWeek(db, tenantId, weekStart); - ensureCutForOpenJobs(db, weekId); - recalcWeek(db, weekId); - const week = db.prepare( +export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) { + const weekId = await ensureWeek(db, tenantId, weekStart); + await ensureCutForOpenJobs(db, weekId); + await recalcWeek(db, weekId); + const week = await db.prepare( `SELECT * FROM payroll_weeks WHERE id=?`, ).get(weekId) as Record; - const period = db.prepare("SELECT * FROM destajo_periods WHERE week_id=?").get(weekId); + const period = await db.prepare("SELECT * FROM destajo_periods WHERE week_id=?").get(weekId); const start = String(week.week_start); const end = String(week.week_end); const days = weekDays(start); - const sheetsRaw = db.prepare( + const sheetsRaw = await db.prepare( `SELECT s.*, p.name AS project_name, p.code AS project_code FROM payroll_sheets s LEFT JOIN projects p ON p.id=s.project_id @@ -888,7 +890,7 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) ORDER BY CASE s.kind WHEN 'obra' THEN 0 WHEN 'destajo' THEN 1 ELSE 2 END, p.name`, ).all(weekId) as Record[]; - const attRows = db.prepare( + const attRows = await db.prepare( `SELECT a.worker_id, a.project_id, a.work_date, a.present, p.name AS project_name FROM attendance a JOIN projects p ON p.id=a.project_id WHERE a.work_date BETWEEN ? AND ?`, @@ -896,12 +898,13 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) worker_id: number; project_id: number; work_date: string; - present: number; + present: boolean; project_name: string; }[]; - const sheets = sheetsRaw.map((sheet) => { - const lines = db.prepare( + const sheets = []; + for (const sheet of sheetsRaw) { + const lines = await db.prepare( `SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, w.position, w.work_type FROM payroll_week_lines l JOIN workers w ON w.id=l.worker_id @@ -914,7 +917,7 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) a.worker_id === line.worker_id && a.project_id === sheet.project_id && a.work_date === date ); const other = attRows.find((a) => - a.worker_id === line.worker_id && a.present === 1 && a.project_id !== sheet.project_id && a.work_date === date + a.worker_id === line.worker_id && a.present === true && a.project_id !== sheet.project_id && a.work_date === date ); return { date, @@ -925,10 +928,10 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) }); return { ...line, cells }; }); - return { ...sheet, lines: withCells }; - }); + sheets.push({ ...sheet, lines: withCells }); + } - const jobs = db.prepare( + const jobs = await db.prepare( `SELECT j.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name FROM destajo_jobs j JOIN workers w ON w.id=j.worker_id @@ -936,9 +939,10 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) WHERE j.tenant_id=? ORDER BY j.status, j.id DESC`, ).all(tenantId) as Record[]; - const jobsOut = jobs.map((job) => ({ ...job, remainder: jobRemainder(db, Number(job.id)) })); + const jobsOut = []; + for (const job of jobs) jobsOut.push({ ...job, remainder: await jobRemainder(db, Number(job.id)) }); const cuts = period - ? db.prepare( + ? await db.prepare( `SELECT c.*, j.concepto, j.unit_code, j.unit_price, j.qty_total_estimated, j.worker_id, j.project_id, j.status AS job_status, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name FROM destajo_cut_lines c @@ -949,18 +953,19 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) ORDER BY p.name, w.last_name_p`, ).all((period as { id: number }).id) : []; - const settings = getSettings(db, tenantId); - const units = listUnits(db, tenantId); + const settings = await getSettings(db, tenantId); + const units = await listUnits(db, tenantId); const required = Number(week.required_net) || 0; const payable = Number(week.payable_net) || 0; - const recoveryRow = db.prepare( + const recoveryRow = await db.prepare( `SELECT COALESCE(SUM(l.loan_discount), 0) AS n FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id WHERE s.week_id=?`, ).get(weekId) as { n: number }; - const loanRecovery = roundMoney(recoveryRow.n); + const loanRecovery = roundMoney(Number(recoveryRow.n)); const faltante = roundMoney(Math.max(0, payable - required)); + const today = await resolveToday(db, tenantId); return { week, destajo_period: period, @@ -977,13 +982,13 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string) faltante, delta: roundMoney(required - payable), }, - can_assemble: canAssembleWeek(start), + can_assemble: canAssembleWeek(start, today), assemble_from: weekAssembleDate(start), }; } -export function weekCsv(db: Database, weekId: number): string { - const lines = db.prepare( +export async function weekCsv(db: Db, weekId: number): Promise { + const lines = await db.prepare( `SELECT w.first_name, w.last_name_p, s.kind, p.name AS project_name, l.days, l.daily_wage, l.concepto, l.qty_actual, l.qty_extra, l.unit_code, l.gross, l.discounts, l.loan_discount, l.loan_label, l.payable_net, l.required_net @@ -1018,8 +1023,8 @@ export function weekCsv(db: Database, weekId: number): string { return [header, ...rows].join("\n"); } -export function listOpenWeeks(db: Database, tenantId: number) { - return db.prepare( +export async function listOpenWeeks(db: Db, tenantId: number) { + return await db.prepare( `SELECT w.*, (SELECT COALESCE(SUM(l.loan_discount), 0) FROM payroll_week_lines l diff --git a/api/payroll_http.ts b/api/payroll_http.ts index ac001f3..df72e83 100644 --- a/api/payroll_http.ts +++ b/api/payroll_http.ts @@ -1,7 +1,8 @@ -import type { Hono } from "hono"; +import type { Context, Hono } from "hono"; import type { AuthUser } from "./auth.ts"; -import { tenantScope, requireAuth } from "./auth.ts"; -import { getDb, lastInsertId, projectById, projectMustBe } from "./db.ts"; +import { tenantScope } from "./auth.ts"; +import { requireCoreAuth } from "./scope.ts"; +import { lastInsertId, projectById, projectMustBe, type Db } from "./db.ts"; import { generateLoanReceiptPdf } from "./pdf.ts"; import { fullName } from "./mx.ts"; import { @@ -19,38 +20,34 @@ import { payWeek, patchDestajoCut, removeAdminLine, + resolveToday, saveSettings, setAttendance, tenantKey, - todayIso, updateAdminLine, weekContaining, weekCsv, } from "./payroll.ts"; -type App = Hono<{ Variables: { user: AuthUser } }>; +type App = Hono<{ Variables: { user: AuthUser; db: Db } }>; function tid(c: { get: (k: "user") => AuthUser }): number { return tenantKey(tenantScope(c.get("user"))); } -async function pdfBody( - c: { header: (k: string, v: string) => void; body: (data: ArrayBuffer) => unknown }, - bytes: Uint8Array, - filename: string, -) { +function pdfBody(c: Context, bytes: Uint8Array, filename: string) { c.header("Content-Type", "application/pdf"); c.header("Content-Disposition", `attachment; filename="${filename}"`); return c.body(bytes.buffer as ArrayBuffer); } export function registerPayrollRoutes(app: App) { - app.get("/v1/attendance", requireAuth, async (c) => { + app.get("/v1/attendance", ...requireCoreAuth, async (c) => { const projectId = Number(c.req.query("project_id")); const from = c.req.query("from") ?? ""; const to = c.req.query("to") ?? ""; - const db = await getDb(); - const rows = db.prepare( + const db = c.get("db"); + const rows = await db.prepare( `SELECT a.*, w.first_name, w.last_name_p FROM attendance a JOIN workers w ON w.id=a.worker_id WHERE a.project_id=? AND a.work_date BETWEEN ? AND ? @@ -59,72 +56,72 @@ export function registerPayrollRoutes(app: App) { return c.json({ attendance: rows }); }); - app.put("/v1/attendance", requireAuth, async (c) => { + app.put("/v1/attendance", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ project_id: number; worker_id: number; work_date: string; present: boolean; }>(); - const db = await getDb(); + const db = c.get("db"); const blocked = projectMustBe( - projectById(db, body.project_id), + await projectById(db, body.project_id), ["activo", "pausado"], "No se registra asistencia en un proyecto concluido o cancelado", ); if (blocked) return c.json({ error: blocked.error }, blocked.status); - const result = setAttendance(db, tid(c), body); + const result = await setAttendance(db, tid(c), body); if ("error" in result) { return c.json({ error: result.error, other: result.other }, result.status === 409 ? 409 : 400); } return c.json({ ok: true }); }); - app.get("/v1/payroll/settings", requireAuth, async (c) => { - const db = await getDb(); + app.get("/v1/payroll/settings", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const tenantId = tid(c); - return c.json({ settings: getSettings(db, tenantId), units: listUnits(db, tenantId) }); + return c.json({ settings: await getSettings(db, tenantId), units: await listUnits(db, tenantId) }); }); - app.patch("/v1/payroll/settings", requireAuth, async (c) => { - const db = await getDb(); + app.patch("/v1/payroll/settings", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const body = await c.req.json<{ loan_commission_enabled?: number | boolean; loan_commission_pct?: number; loan_small_max?: number; }>(); - const settings = saveSettings(db, tid(c), { + const settings = await saveSettings(db, tid(c), { loan_commission_enabled: body.loan_commission_enabled === undefined ? undefined - : body.loan_commission_enabled ? 1 : 0, + : Boolean(body.loan_commission_enabled), loan_commission_pct: body.loan_commission_pct, loan_small_max: body.loan_small_max, }); return c.json({ settings }); }); - app.post("/v1/destajo/units", requireAuth, async (c) => { + app.post("/v1/destajo/units", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ code: string; label?: string }>(); - const db = await getDb(); + const db = c.get("db"); try { - const id = addUnit(db, tid(c), body.code, body.label ?? body.code); + const id = await addUnit(db, tid(c), body.code, body.label ?? body.code); return c.json({ id }, 201); } catch (e) { return c.json({ error: e instanceof Error ? e.message : "No se pudo guardar" }, 400); } }); - app.get("/v1/loans", requireAuth, async (c) => { - const db = await getDb(); + app.get("/v1/loans", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const workerId = c.req.query("worker_id"); const sql = workerId ? "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.worker_id=? ORDER BY l.id DESC" : "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id ORDER BY l.id DESC"; - const loans = workerId ? db.prepare(sql).all(Number(workerId)) : db.prepare(sql).all(); + const loans = workerId ? await db.prepare(sql).all(Number(workerId)) : await db.prepare(sql).all(); return c.json({ loans }); }); - app.post("/v1/loans", requireAuth, async (c) => { + app.post("/v1/loans", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ worker_id: number; amount?: number; @@ -136,9 +133,9 @@ export function registerPayrollRoutes(app: App) { plan?: "single" | "installments"; installments_n?: number; }>(); - const db = await getDb(); + const db = c.get("db"); try { - const created = createLoan(db, tid(c), { + const created = await createLoan(db, tid(c), { worker_id: body.worker_id, delivered: Number(body.delivered ?? body.amount ?? 0), commission_enabled: body.commission_enabled, @@ -154,10 +151,10 @@ export function registerPayrollRoutes(app: App) { } }); - app.get("/v1/loans/:id/recibo", requireAuth, async (c) => { + app.get("/v1/loans/:id/recibo", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const loan = db.prepare( + const db = c.get("db"); + const loan = await db.prepare( `SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.id=?`, ).get(id) as { @@ -202,10 +199,10 @@ export function registerPayrollRoutes(app: App) { return pdfBody(c, bytes, `prestamo-aceptacion-${id}.pdf`); }); - app.get("/v1/loan-payments/:id/recibo", requireAuth, async (c) => { + app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const pay = db.prepare( + const db = c.get("db"); + const pay = await db.prepare( `SELECT p.*, l.delivered, l.commission_pct, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, wk.week_end FROM loan_payments p JOIN loans l ON l.id=p.loan_id @@ -238,23 +235,24 @@ export function registerPayrollRoutes(app: App) { return pdfBody(c, bytes, `prestamo-pago-${id}.pdf`); }); - app.get("/v1/payroll/weeks", requireAuth, async (c) => { - const db = await getDb(); + app.get("/v1/payroll/weeks", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const tenantId = tid(c); - const start = c.req.query("week_start") || weekContaining(todayIso()).weekStart; - const bundle = getWeekBundle(db, tenantId, start); + const start = c.req.query("week_start") || weekContaining(await resolveToday(db, tenantId)).weekStart; + const bundle = await getWeekBundle(db, tenantId, start); return c.json(bundle); }); - app.get("/v1/payroll/weeks/open", requireAuth, async (c) => { - const db = await getDb(); - return c.json({ weeks: listOpenWeeks(db, tid(c)) }); + app.get("/v1/payroll/weeks/open", ...requireCoreAuth, async (c) => { + const db = c.get("db"); + return c.json({ weeks: await listOpenWeeks(db, tid(c)) }); }); - app.post("/v1/payroll/weeks/:id/assemble", requireAuth, async (c) => { - const db = await getDb(); + app.post("/v1/payroll/weeks/:id/assemble", ...requireCoreAuth, async (c) => { + const db = c.get("db"); try { - assembleWeek(db, Number(c.req.param("id"))); + const nowIso = await resolveToday(db, tid(c)); + await assembleWeek(db, Number(c.req.param("id")), nowIso); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -262,10 +260,11 @@ export function registerPayrollRoutes(app: App) { } }); - app.post("/v1/payroll/weeks/:id/pay", requireAuth, async (c) => { - const db = await getDb(); + app.post("/v1/payroll/weeks/:id/pay", ...requireCoreAuth, async (c) => { + const db = c.get("db"); try { - payWeek(db, Number(c.req.param("id"))); + const nowIso = await resolveToday(db, tid(c)); + await payWeek(db, Number(c.req.param("id")), nowIso); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -273,20 +272,20 @@ export function registerPayrollRoutes(app: App) { } }); - app.get("/v1/payroll/weeks/:id/csv", requireAuth, async (c) => { + app.get("/v1/payroll/weeks/:id/csv", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const csv = weekCsv(db, id); + const db = c.get("db"); + const csv = await weekCsv(db, id); c.header("Content-Type", "text/csv; charset=utf-8"); c.header("Content-Disposition", `attachment; filename="nomina-${id}.csv"`); return c.body(csv); }); - app.post("/v1/payroll/weeks/:id/jornal", requireAuth, async (c) => { + app.post("/v1/payroll/weeks/:id/jornal", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ project_id: number; worker_id: number }>(); - const db = await getDb(); + const db = c.get("db"); try { - addJornalWorker(db, Number(c.req.param("id")), body.project_id, body.worker_id); + await addJornalWorker(db, Number(c.req.param("id")), body.project_id, body.worker_id); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -294,11 +293,11 @@ export function registerPayrollRoutes(app: App) { } }); - app.post("/v1/payroll/weeks/:id/admin", requireAuth, async (c) => { + app.post("/v1/payroll/weeks/:id/admin", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ worker_id: number; amount: number; project_id?: number | null }>(); - const db = await getDb(); + const db = c.get("db"); try { - addAdminLine(db, Number(c.req.param("id")), body); + await addAdminLine(db, Number(c.req.param("id")), body); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -306,11 +305,11 @@ export function registerPayrollRoutes(app: App) { } }); - app.patch("/v1/payroll/weeks/:id/admin/:lineId", requireAuth, async (c) => { + app.patch("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ amount: number }>(); - const db = await getDb(); + const db = c.get("db"); try { - updateAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId")), body.amount); + await updateAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId")), body.amount); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -318,10 +317,10 @@ export function registerPayrollRoutes(app: App) { } }); - app.delete("/v1/payroll/weeks/:id/admin/:lineId", requireAuth, async (c) => { - const db = await getDb(); + app.delete("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => { + const db = c.get("db"); try { - removeAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId"))); + await removeAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId"))); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -329,7 +328,7 @@ export function registerPayrollRoutes(app: App) { } }); - app.post("/v1/destajo/jobs", requireAuth, async (c) => { + app.post("/v1/destajo/jobs", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ week_id: number; project_id: number; @@ -341,9 +340,9 @@ export function registerPayrollRoutes(app: App) { qty_planned?: number; qty_extra?: number; }>(); - const db = await getDb(); + const db = c.get("db"); try { - const created = createDestajoJob(db, tid(c), body.week_id, body); + const created = await createDestajoJob(db, tid(c), body.week_id, body); return c.json(created, 201); } catch (e) { const err = e as Error & { status?: number }; @@ -351,16 +350,16 @@ export function registerPayrollRoutes(app: App) { } }); - app.patch("/v1/destajo/cuts/:id", requireAuth, async (c) => { + app.patch("/v1/destajo/cuts/:id", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ week_id: number; qty_planned?: number; qty_actual?: number; qty_extra?: number; }>(); - const db = await getDb(); + const db = c.get("db"); try { - patchDestajoCut(db, body.week_id, Number(c.req.param("id")), body); + await patchDestajoCut(db, body.week_id, Number(c.req.param("id")), body); return c.json({ ok: true }); } catch (e) { const err = e as Error & { status?: number }; @@ -368,27 +367,27 @@ export function registerPayrollRoutes(app: App) { } }); - app.post("/v1/payroll/periods", requireAuth, async (c) => { + app.post("/v1/payroll/periods", ...requireCoreAuth, async (c) => { const body = await c.req.json<{ project_id: number; week_start: string; week_end: string; extra_discounts?: Record; }>(); - const db = await getDb(); + const db = c.get("db"); const blocked = projectMustBe( - projectById(db, body.project_id), + await projectById(db, body.project_id), ["activo", "pausado"], "No se genera nómina de un proyecto concluido o cancelado", ); if (blocked) return c.json({ error: blocked.error }, blocked.status); - db.prepare( + await db.prepare( "INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')", ).run(body.project_id, body.week_start, body.week_end); - const periodId = lastInsertId(db); - const workers = db.prepare( + const periodId = await lastInsertId(db); + const workers = await db.prepare( `SELECT w.id, w.daily_wage FROM workers w - JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=1 + JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=true WHERE w.status='activo'`, ).all(body.project_id) as { id: number; daily_wage: number }[]; const ins = db.prepare( @@ -396,47 +395,47 @@ export function registerPayrollRoutes(app: App) { VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ); for (const w of workers) { - const att = db.prepare( + const att = await db.prepare( `SELECT COUNT(*) AS n FROM attendance - WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=1`, + WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`, ).get(w.id, body.project_id, body.week_start, body.week_end) as { n: number }; - const days = att.n; + const days = Number(att.n); const gross = days * w.daily_wage; const extra = Number(body.extra_discounts?.[w.id] ?? 0); const net = gross - extra; - ins.run(periodId, w.id, days, w.daily_wage, gross, extra, 0, net); + await ins.run(periodId, w.id, days, w.daily_wage, gross, extra, 0, net); } return c.json({ id: periodId }); }); - app.get("/v1/payroll/periods", requireAuth, async (c) => { - const db = await getDb(); + app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => { + const db = c.get("db"); const projectId = c.req.query("project_id"); const sql = projectId ? `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.project_id=? ORDER BY pe.id DESC` : `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id ORDER BY pe.id DESC`; - const periods = projectId ? db.prepare(sql).all(Number(projectId)) : db.prepare(sql).all(); + const periods = projectId ? await db.prepare(sql).all(Number(projectId)) : await db.prepare(sql).all(); return c.json({ periods }); }); - app.get("/v1/payroll/periods/:id", requireAuth, async (c) => { + app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const period = db.prepare( + const db = c.get("db"); + const period = await db.prepare( `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.id=?`, ).get(id); if (!period) return c.json({ error: "Periodo no encontrado" }, 404); - const lines = db.prepare( + const lines = await db.prepare( `SELECT l.*, w.first_name, w.last_name_p, w.position FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=? ORDER BY w.last_name_p`, ).all(id); return c.json({ period, lines }); }); - app.get("/v1/payroll/periods/:id/csv", requireAuth, async (c) => { + app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); - const db = await getDb(); - const lines = db.prepare( + const db = c.get("db"); + const lines = await db.prepare( `SELECT w.first_name, w.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`, ).all(id) as Record[]; @@ -450,11 +449,11 @@ export function registerPayrollRoutes(app: App) { return c.body(csv); }); - app.patch("/v1/payroll/periods/:id", requireAuth, async (c) => { + app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const { status } = await c.req.json<{ status: string }>(); - const db = await getDb(); - db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id); + const db = c.get("db"); + await db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id); return c.json({ ok: true }); }); } diff --git a/api/payroll_test.ts b/api/payroll_test.ts index fb0d383..981ea10 100644 --- a/api/payroll_test.ts +++ b/api/payroll_test.ts @@ -1,5 +1,6 @@ -import { assertEquals, assertExists, assertThrows } from "jsr:@std/assert@1"; -import { Database } from "@db/sqlite"; +import { assertEquals, assertExists, assertRejects } from "jsr:@std/assert@1"; +import type { PgDb } from "./pg.ts"; +import { withTestDb } from "./test_helpers.ts"; import { addDays, assembleWeek, @@ -22,92 +23,58 @@ import { weekContaining, } from "./payroll.ts"; -function mem() { - const db = new Database(":memory:"); - db.exec("PRAGMA foreign_keys = ON"); - db.exec(` - CREATE TABLE companies (id INTEGER PRIMARY KEY, name TEXT, tenant_id INTEGER); - CREATE TABLE projects ( - id INTEGER PRIMARY KEY, code TEXT, name TEXT, status TEXT DEFAULT 'activo', tenant_id INTEGER - ); - CREATE TABLE workers ( - id INTEGER PRIMARY KEY, - first_name TEXT, last_name_p TEXT, position TEXT DEFAULT 'Ayudante', - work_type TEXT DEFAULT 'N', daily_wage REAL DEFAULT 0, - status TEXT DEFAULT 'activo', pipeline_status TEXT DEFAULT 'activo', tenant_id INTEGER - ); - CREATE TABLE assignments ( - id INTEGER PRIMARY KEY, worker_id INTEGER, project_id INTEGER, active INTEGER DEFAULT 1 - ); - CREATE TABLE attendance ( - id INTEGER PRIMARY KEY, - worker_id INTEGER, project_id INTEGER, work_date TEXT, present INTEGER DEFAULT 1, - UNIQUE (worker_id, project_id, work_date) - ); - CREATE TABLE loans ( - id INTEGER PRIMARY KEY, - worker_id INTEGER, amount REAL, delivered REAL DEFAULT 0, balance REAL, - weekly_payment REAL DEFAULT 0, note TEXT, - commission_pct REAL DEFAULT 0, commission_amount REAL DEFAULT 0, - plan TEXT DEFAULT 'single', installments_n INTEGER DEFAULT 1, first_due TEXT, - created_at TEXT DEFAULT (datetime('now')) - ); - CREATE TABLE payroll_settings ( - tenant_id INTEGER PRIMARY KEY, - loan_commission_enabled INTEGER DEFAULT 1, - loan_commission_pct REAL DEFAULT 10, - loan_small_max REAL DEFAULT 500 - ); - CREATE TABLE payroll_weeks ( - id INTEGER PRIMARY KEY, - week_start TEXT, week_end TEXT, status TEXT DEFAULT 'draft', - required_net REAL DEFAULT 0, payable_net REAL DEFAULT 0, - assembled_at TEXT, paid_at TEXT, tenant_id INTEGER DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')) - ); - CREATE UNIQUE INDEX idx_weeks ON payroll_weeks(tenant_id, week_start); - CREATE TABLE payroll_sheets ( - id INTEGER PRIMARY KEY, week_id INTEGER, kind TEXT, project_id INTEGER - ); - CREATE UNIQUE INDEX idx_sheets ON payroll_sheets(week_id, kind, IFNULL(project_id, 0)); - CREATE TABLE payroll_week_lines ( - id INTEGER PRIMARY KEY, - sheet_id INTEGER, worker_id INTEGER, destajo_cut_line_id INTEGER, project_id INTEGER, - days REAL DEFAULT 0, daily_wage REAL DEFAULT 0, - qty_planned REAL DEFAULT 0, qty_actual REAL DEFAULT 0, qty_extra REAL DEFAULT 0, - unit_price REAL DEFAULT 0, unit_code TEXT, concepto TEXT, - amount REAL DEFAULT 0, gross REAL DEFAULT 0, discounts REAL DEFAULT 0, - loan_id INTEGER, loan_discount REAL DEFAULT 0, loan_label TEXT, - required_net REAL DEFAULT 0, payable_net REAL DEFAULT 0 - ); - CREATE TABLE destajo_units ( - id INTEGER PRIMARY KEY, tenant_id INTEGER, code TEXT, label TEXT, UNIQUE(tenant_id, code) - ); - CREATE TABLE destajo_periods ( - id INTEGER PRIMARY KEY, period_start TEXT, period_end TEXT, week_id INTEGER, tenant_id INTEGER, - UNIQUE(tenant_id, period_end) - ); - CREATE TABLE destajo_jobs ( - id INTEGER PRIMARY KEY, project_id INTEGER, worker_id INTEGER, concepto TEXT, - unit_code TEXT, qty_total_estimated REAL, unit_price REAL, status TEXT DEFAULT 'open', - tenant_id INTEGER DEFAULT 0 - ); - CREATE TABLE destajo_cut_lines ( - id INTEGER PRIMARY KEY, period_id INTEGER, job_id INTEGER, - qty_planned REAL DEFAULT 0, qty_actual REAL DEFAULT 0, qty_extra REAL DEFAULT 0, - UNIQUE(period_id, job_id) - ); - CREATE TABLE loan_payments ( - id INTEGER PRIMARY KEY, loan_id INTEGER, week_id INTEGER, amount REAL, - installment_n INTEGER, label TEXT, created_at TEXT DEFAULT (datetime('now')) - ); - `); - db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (1, 'PRY-0001', 'Obra Norte', 'activo', 1)").run(); - db.prepare( - "INSERT INTO workers (id, first_name, last_name_p, work_type, daily_wage, tenant_id) VALUES (1, 'Juan', 'Perez', 'N', 500, 1)", - ).run(); - db.prepare("INSERT INTO assignments (worker_id, project_id, active) VALUES (1, 1, 1)").run(); - return db; +// tenant_id fijo y solo-de-tests: cada test corre en su propia transacción +// que siempre se revierte (ver test_helpers.ts), así que reusar el mismo +// id entre tests nunca colisiona -- nada de esto llega a persistir. +const TENANT_ID = 999001; + +type Fixture = { companyId: number; projectId: number; workerId: number }; + +async function seedFixture(db: PgDb, opts: { workType?: "N" | "D"; dailyWage?: number } = {}): Promise { + await db.prepare( + `INSERT INTO companies (code, name, kind, tenant_id) VALUES ('TSTCO', 'Test Co', 'principal', ?)`, + ).run(TENANT_ID); + const companyId = await db.lastInsertId(); + + await db.prepare( + `INSERT INTO projects (code, name, status, theme_id, company_id, tenant_id) + VALUES ('TST-0001', 'Obra Norte', 'activo', 'arctec-dos-logos-fold', ?, ?)`, + ).run(companyId, TENANT_ID); + const projectId = await db.lastInsertId(); + + await db.prepare( + `INSERT INTO workers + (first_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, + hire_type, company_id, position, risk_code, work_type, daily_wage, tenant_id) + VALUES ('Juan', 'Perez', 'Lopez', 'CURPTEST0001', 'RFCTEST0001', 'NSSTEST0001', '9990000001', + 'juan@example.com', 'Sin domicilio', 'TSTCO', ?, 'Ayudante', 'rojo', ?, ?, ?)`, + ).run(companyId, opts.workType ?? "N", opts.dailyWage ?? 500, TENANT_ID); + const workerId = await db.lastInsertId(); + + await db.prepare( + `INSERT INTO assignments (worker_id, project_id, active, start_date) VALUES (?, ?, true, current_date)`, + ).run(workerId, projectId); + + return { companyId, projectId, workerId }; +} + +async function addSecondProject(db: PgDb, companyId: number): Promise { + await db.prepare( + `INSERT INTO projects (code, name, status, theme_id, company_id, tenant_id) + VALUES ('TST-0002', 'Obra Sur', 'activo', 'arctec-dos-logos-fold', ?, ?)`, + ).run(companyId, TENANT_ID); + return await db.lastInsertId(); +} + +async function addSecondWorker(db: PgDb, companyId: number): Promise { + await db.prepare( + `INSERT INTO workers + (first_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, + hire_type, company_id, position, risk_code, work_type, daily_wage, tenant_id) + VALUES ('Luis', 'Destajo', 'Lopez', 'CURPTEST0002', 'RFCTEST0002', 'NSSTEST0002', '9990000002', + 'luis@example.com', 'Sin domicilio', 'TSTCO', ?, 'Ayudante', 'rojo', 'D', 0, ?)`, + ).run(companyId, TENANT_ID); + return await db.lastInsertId(); } Deno.test("semana lun-sáb y corte destajo jue-jue", () => { @@ -119,25 +86,28 @@ Deno.test("semana lun-sáb y corte destajo jue-jue", () => { assertEquals(d.periodStart, "2026-08-20"); }); -Deno.test("armar jueves solo a partir del jueves de esa semana", () => { +Deno.test("armar jueves solo a partir del jueves de esa semana", async () => { assertEquals(weekAssembleDate("2026-08-24"), "2026-08-27"); assertEquals(canAssembleWeek("2026-08-24", "2026-08-26"), false); assertEquals(canAssembleWeek("2026-08-24", "2026-08-27"), true); assertEquals(canAssembleWeek("2026-08-24", "2026-08-29"), true); assertEquals(canAssembleWeek("2026-08-24", "2026-08-31"), true); assertEquals(canAssembleWeek("2026-08-31", "2026-08-26"), false); - const db = mem(); - const weekId = ensureWeek(db, 1, "2026-08-24"); - assertThrows( - () => assembleWeek(db, weekId, "2026-08-26"), - Error, - "jueves", - ); - const draft = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string }; - assertEquals(draft.status, "draft"); - assembleWeek(db, weekId, "2026-08-28"); - const after = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string }; - assertEquals(after.status, "assembled"); + + await withTestDb(async (db) => { + await seedFixture(db); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + await assertRejects( + () => assembleWeek(db, weekId, "2026-08-26"), + Error, + "jueves", + ); + const draft = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string }; + assertEquals(draft.status, "draft"); + await assembleWeek(db, weekId, "2026-08-28"); + const after = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string }; + assertEquals(after.status, "assembled"); + }); }); Deno.test("comisión suma al saldo", () => { @@ -180,144 +150,181 @@ Deno.test("préstamo chico vence el sábado de la semana siguiente", () => { ); }); -Deno.test("armar jueves prellena jue-vie-sáb y congela requerida", () => { - const db = mem(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run(); - const weekId = ensureWeek(db, 1, "2026-08-24"); - assembleWeek(db, weekId, "2026-08-27"); - const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { - status: string; - required_net: number; - payable_net: number; - }; - assertEquals(week.status, "assembled"); - assertEquals(week.required_net, 2500); - assertEquals(week.payable_net, 2500); - const thu = db.prepare( - "SELECT present FROM attendance WHERE worker_id=1 AND work_date='2026-08-27'", - ).get() as { present: number }; - assertEquals(thu.present, 1); -}); - -Deno.test("falta viernes baja a pagar y no toca requerida", () => { - const db = mem(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run(); - const weekId = ensureWeek(db, 1, "2026-08-24"); - assembleWeek(db, weekId, "2026-08-27"); - const r = setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-28", present: false }); - assertEquals("ok" in r && r.ok, true); - const week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { - required_net: number; - payable_net: number; - }; - assertEquals(week.required_net, 2500); - assertEquals(week.payable_net, 2000); -}); - -Deno.test("préstamo no baja saldo al armar; sí al pagar", () => { - const db = mem(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-26', 1)").run(); - createLoan(db, 1, { worker_id: 1, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" }); - const weekId = ensureWeek(db, 1, "2026-08-24"); - assembleWeek(db, weekId, "2026-08-27"); - const before = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number }; - assertEquals(before.balance, 2200); - const week = db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number }; - assertEquals(week.required_net, 800); - payWeek(db, weekId); - const after = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number }; - assertEquals(after.balance, 0); -}); - -Deno.test("tablero: recuperación de préstamos no es sobrante", () => { - const db = mem(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run(); - db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run(); - const weekId = ensureWeek(db, 1, "2026-08-24"); - assembleWeek(db, weekId, "2026-08-27"); - createLoan(db, 1, { worker_id: 1, delivered: 1000, commission_pct: 20, grantIso: "2026-08-24" }); - const bundle = getWeekBundle(db, 1, "2026-08-24"); - const board = bundle.board as { - required_net: number; - payable_net: number; - loan_recovery: number; - faltante: number; - }; - assertEquals(board.required_net, 2500); - assertEquals(board.loan_recovery, 1200); - assertEquals(board.payable_net, 1300); - assertEquals(board.faltante, 0); -}); - -Deno.test("un día no se marca en dos obras", () => { - const db = mem(); - db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run(); - setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-24", present: true }); - const blocked = setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-24", present: true }); - assertEquals("error" in blocked, true); -}); - -Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", () => { - const db = mem(); - db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run(); - setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-27", present: true }); - const weekId = ensureWeek(db, 1, "2026-08-24"); - assembleWeek(db, weekId, "2026-08-27"); - const onA = db.prepare( - "SELECT present FROM attendance WHERE worker_id=1 AND project_id=1 AND work_date='2026-08-27'", - ).get() as { present: number } | undefined; - assertEquals(onA, undefined); - const onB = db.prepare( - "SELECT present FROM attendance WHERE worker_id=1 AND project_id=2 AND work_date='2026-08-27'", - ).get() as { present: number }; - assertEquals(onB.present, 1); -}); - - -Deno.test("destajo: estimado del corte, realizado menor, remanente y extra viernes", () => { - const db = mem(); - db.prepare("DELETE FROM assignments WHERE worker_id=1").run(); - db.prepare( - "INSERT INTO workers (id, first_name, last_name_p, work_type, daily_wage, tenant_id) VALUES (2, 'Luis', 'Destajo', 'D', 0, 1)", - ).run(); - const weekId = ensureWeek(db, 1, "2026-08-24"); - const job = createDestajoJob(db, 1, weekId, { - project_id: 1, - worker_id: 2, - concepto: "Muro tablaroca", - unit_code: "m2", - qty_total_estimated: 200, - unit_price: 100, - qty_planned: 80, +Deno.test("armar jueves prellena jue-vie-sáb y congela requerida", async () => { + await withTestDb(async (db) => { + const { projectId, workerId } = await seedFixture(db); + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-24', true)", + ).run(workerId, projectId); + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)", + ).run(workerId, projectId); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + await assembleWeek(db, weekId, "2026-08-27"); + const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { + status: string; + required_net: number; + payable_net: number; + }; + assertEquals(week.status, "assembled"); + assertEquals(week.required_net, 2500); + assertEquals(week.payable_net, 2500); + const thu = await db.prepare( + "SELECT present FROM attendance WHERE worker_id=? AND work_date='2026-08-27'", + ).get(workerId) as { present: boolean }; + assertEquals(thu.present, true); + }); +}); + +Deno.test("falta viernes baja a pagar y no toca requerida", async () => { + await withTestDb(async (db) => { + const { projectId, workerId } = await seedFixture(db); + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-24', true)", + ).run(workerId, projectId); + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)", + ).run(workerId, projectId); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + await assembleWeek(db, weekId, "2026-08-27"); + const r = await setAttendance(db, TENANT_ID, { + project_id: projectId, + worker_id: workerId, + work_date: "2026-08-28", + present: false, + }); + assertEquals("ok" in r && r.ok, true); + const week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { + required_net: number; + payable_net: number; + }; + assertEquals(week.required_net, 2500); + assertEquals(week.payable_net, 2000); + }); +}); + +Deno.test("préstamo no baja saldo al armar; sí al pagar", async () => { + await withTestDb(async (db) => { + const { projectId, workerId } = await seedFixture(db); + for (const day of ["2026-08-24", "2026-08-25", "2026-08-26"]) { + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, true)", + ).run(workerId, projectId, day); + } + await createLoan(db, TENANT_ID, { worker_id: workerId, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" }); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + await assembleWeek(db, weekId, "2026-08-27"); + const before = await db.prepare("SELECT balance FROM loans WHERE worker_id=?").get(workerId) as { balance: number }; + assertEquals(before.balance, 2200); + const week = await db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number }; + assertEquals(week.required_net, 800); + await payWeek(db, weekId, "2026-08-29"); + const after = await db.prepare("SELECT balance FROM loans WHERE worker_id=?").get(workerId) as { balance: number }; + assertEquals(after.balance, 0); + }); +}); + +Deno.test("tablero: recuperación de préstamos no es sobrante", async () => { + await withTestDb(async (db) => { + const { projectId, workerId } = await seedFixture(db); + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-24', true)", + ).run(workerId, projectId); + await db.prepare( + "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)", + ).run(workerId, projectId); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + await assembleWeek(db, weekId, "2026-08-27"); + await createLoan(db, TENANT_ID, { worker_id: workerId, delivered: 1000, commission_pct: 20, grantIso: "2026-08-24" }); + const bundle = await getWeekBundle(db, TENANT_ID, "2026-08-24"); + const board = bundle.board as { + required_net: number; + payable_net: number; + loan_recovery: number; + faltante: number; + }; + assertEquals(board.required_net, 2500); + assertEquals(board.loan_recovery, 1200); + assertEquals(board.payable_net, 1300); + assertEquals(board.faltante, 0); + }); +}); + +Deno.test("un día no se marca en dos obras", async () => { + await withTestDb(async (db) => { + const { companyId, projectId, workerId } = await seedFixture(db); + const project2Id = await addSecondProject(db, companyId); + await setAttendance(db, TENANT_ID, { project_id: projectId, worker_id: workerId, work_date: "2026-08-24", present: true }); + const blocked = await setAttendance(db, TENANT_ID, { + project_id: project2Id, + worker_id: workerId, + work_date: "2026-08-24", + present: true, + }); + assertEquals("error" in blocked, true); + }); +}); + +Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", async () => { + await withTestDb(async (db) => { + const { companyId, projectId, workerId } = await seedFixture(db); + const project2Id = await addSecondProject(db, companyId); + await setAttendance(db, TENANT_ID, { project_id: project2Id, worker_id: workerId, work_date: "2026-08-27", present: true }); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + await assembleWeek(db, weekId, "2026-08-27"); + const onA = await db.prepare( + "SELECT present FROM attendance WHERE worker_id=? AND project_id=? AND work_date='2026-08-27'", + ).get(workerId, projectId) as { present: boolean } | undefined; + assertEquals(onA, undefined); + const onB = await db.prepare( + "SELECT present FROM attendance WHERE worker_id=? AND project_id=? AND work_date='2026-08-27'", + ).get(workerId, project2Id) as { present: boolean }; + assertEquals(onB.present, true); + }); +}); + +Deno.test("destajo: estimado del corte, realizado menor, remanente y extra viernes", async () => { + await withTestDb(async (db) => { + const { companyId, projectId, workerId } = await seedFixture(db); + // Igual que el original: el jornalero de la fixture no debe seguir en + // el roster de "obra", para que el requerido sea solo el destajo. + await db.prepare("DELETE FROM assignments WHERE worker_id=?").run(workerId); + const worker2Id = await addSecondWorker(db, companyId); + const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24"); + const job = await createDestajoJob(db, TENANT_ID, weekId, { + project_id: projectId, + worker_id: worker2Id, + concepto: "Muro tablaroca", + unit_code: "m2", + qty_total_estimated: 200, + unit_price: 100, + qty_planned: 80, + }); + await assembleWeek(db, weekId, "2026-08-27"); + let week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { + required_net: number; + payable_net: number; + }; + assertEquals(week.required_net, 8000); + const cut = await db.prepare("SELECT id FROM destajo_cut_lines WHERE job_id=?").get(job.id) as { id: number }; + await patchDestajoCut(db, weekId, cut.id, { qty_actual: 50 }); + week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { + required_net: number; + payable_net: number; + }; + assertEquals(week.required_net, 8000); + assertEquals(week.payable_net, 5000); + await patchDestajoCut(db, weekId, cut.id, { qty_extra: 10 }); + week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { + required_net: number; + payable_net: number; + }; + assertEquals(week.payable_net, 6000); + await payWeek(db, weekId, "2026-08-29"); + assertEquals(await jobRemainder(db, job.id), 140); + const nextId = await ensureWeek(db, TENANT_ID, addDays("2026-08-24", 7)); + assertExists(nextId); + const bundle = await getWeekBundle(db, TENANT_ID, "2026-08-31"); + assertEquals((bundle.destajo_jobs as { remainder: number }[])[0].remainder, 140); }); - assembleWeek(db, weekId, "2026-08-27"); - let week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { - required_net: number; - payable_net: number; - }; - assertEquals(week.required_net, 8000); - const cut = db.prepare("SELECT id FROM destajo_cut_lines WHERE job_id=?").get(job.id) as { id: number }; - patchDestajoCut(db, weekId, cut.id, { qty_actual: 50 }); - week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { - required_net: number; - payable_net: number; - }; - assertEquals(week.required_net, 8000); - assertEquals(week.payable_net, 5000); - patchDestajoCut(db, weekId, cut.id, { qty_extra: 10 }); - week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { - required_net: number; - payable_net: number; - }; - assertEquals(week.payable_net, 6000); - payWeek(db, weekId); - assertEquals(jobRemainder(db, job.id), 140); - const nextId = ensureWeek(db, 1, addDays("2026-08-24", 7)); - assertExists(nextId); - const bundle = getWeekBundle(db, 1, "2026-08-31"); - assertEquals((bundle.destajo_jobs as { remainder: number }[])[0].remainder, 140); }); diff --git a/api/pdf.ts b/api/pdf.ts index b53071b..6a40a3b 100644 --- a/api/pdf.ts +++ b/api/pdf.ts @@ -1,13 +1,10 @@ -import type { Database } from "@db/sqlite"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; -// mkdir used in saveJobPdf import QRCode from "qrcode"; import { PDFDocument, StandardFonts, rgb, degrees, type PDFPage, type PDFFont, type PDFImage } from "pdf-lib"; -import { config, PDFS_DIR } from "./config.ts"; +import { config } from "./config.ts"; import { decryptBytes } from "./docs_crypto.ts"; import { fullName, frontName } from "./mx.ts"; -import { workerDir } from "./db.ts"; +import type { Db } from "./db.ts"; +import { badgeJobPdfKey, getObject, putObject, workerDocKey } from "./storage.ts"; const CM = 28.346456692913385; const CARD_W = 6.7 * CM; @@ -53,15 +50,15 @@ function hexRgb(hex: string) { ); } -export async function loadCurrentPhoto(db: Database, workerId: number): Promise { - const doc = db.prepare( +export async function loadCurrentPhoto(db: Db, workerId: number): Promise { + const doc = await db.prepare( `SELECT storage_name, iv FROM documents - WHERE worker_id = ? AND type_code = 'foto' AND is_current = 1 + WHERE worker_id = ? AND type_code = 'foto' AND is_current = true ORDER BY id DESC LIMIT 1`, ).get(workerId) as { storage_name: string; iv: string } | undefined; if (!doc) return null; try { - const enc = await Deno.readFile(join(workerDir(workerId), doc.storage_name)); + const enc = await getObject(workerDocKey(workerId, doc.storage_name)); return await decryptBytes(doc.iv, enc); } catch { return null; @@ -99,11 +96,11 @@ function drawCentered( } export async function generateBadgePdf( - db: Database, + db: Db, projectId: number, workerIds: number[], ): Promise { - const project = db.prepare( + const project = await db.prepare( "SELECT name, code, theme_id, logo_left_path, logo_right_path FROM projects WHERE id = ?", ).get(projectId) as { name: string; @@ -114,15 +111,14 @@ export async function generateBadgePdf( } | undefined; if (!project) throw new Error("Proyecto no encontrado"); - const placeholders = workerIds.map(() => "?").join(","); - const workers = db.prepare( + const workers = await db.prepare( `SELECT w.id, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, w.curp, w.nss, w.blood_type, w.position, r.color AS risk_color, r.text_color AS risk_text FROM workers w JOIN risk_levels r ON r.code = w.risk_code - WHERE w.id IN (${placeholders})`, - ).all(...workerIds) as WorkerRow[]; + WHERE w.id = ANY(?)`, + ).all(workerIds) as WorkerRow[]; const byId = new Map(workers.map((w) => [w.id, w])); const ordered = workerIds.map((id) => byId.get(id)).filter(Boolean) as WorkerRow[]; @@ -135,12 +131,12 @@ export async function generateBadgePdf( let logoR: PDFImage | null = null; if (project.logo_left_path) { try { - logoL = await embedMaybe(pdf, await Deno.readFile(project.logo_left_path)); + logoL = await embedMaybe(pdf, await getObject(project.logo_left_path)); } catch { /* text fallback */ } } if (project.logo_right_path) { try { - logoR = await embedMaybe(pdf, await Deno.readFile(project.logo_right_path)); + logoR = await embedMaybe(pdf, await getObject(project.logo_right_path)); } catch { /* text fallback */ } } @@ -238,7 +234,6 @@ function drawFront( page.drawImage(photo, { x: px + 1, y: py + 1, width: photoW - 2, height: photoH - 2 }); } - const name = frontName(w).toUpperCase(); drawCentered(page, fontBold, w.first_name.toUpperCase(), x, py - 22, CARD_W, 13); drawCentered(page, fontBold, w.last_name_p.toUpperCase(), x, py - 38, CARD_W, 13); @@ -274,8 +269,6 @@ function drawBack( projectName: string, projectCode: string, ) { - const cx = x + CARD_W / 2; - const cy = y + CARD_H / 2; page.drawRectangle({ x, y, @@ -284,11 +277,7 @@ function drawBack( borderColor: rgb(0, 0, 0), borderWidth: 1.5, rotate: degrees(180), - // pdf-lib rotate is around origin of the op; we translate via origin option }); - // Draw rotated content using origin at card center - const opts = { rotate: degrees(180) as ReturnType }; - page.drawRectangle({ x: x + CARD_W, y: y + CARD_H, @@ -343,10 +332,9 @@ function drawBack( } export async function saveJobPdf(bytes: Uint8Array, jobId: number): Promise { - await mkdir(PDFS_DIR, { recursive: true }); - const path = join(PDFS_DIR, `gafetes-${jobId}.pdf`); - await Deno.writeFile(path, bytes); - return path; + const key = badgeJobPdfKey(jobId); + await putObject(key, bytes); + return key; } function wrap(text: string, font: PDFFont, size: number, max: number): string[] { diff --git a/api/pg.ts b/api/pg.ts new file mode 100644 index 0000000..80d074c --- /dev/null +++ b/api/pg.ts @@ -0,0 +1,138 @@ +import postgres from "npm:postgres@3"; + +/** + * Adaptador delgado sobre postgres.js que imita la forma + * `db.prepare(sql).get/all/run(...params)` que tenía el driver SQLite + * (@db/sqlite), para poder migrar ~80 endpoints sin reescribir cada query + * a mano en el mismo cambio que separa las bases/esquemas. Sigue siendo + * 100% parametrizado (nunca concatena valores en el texto SQL) -- lo único + * que cambia es la forma superficial de la llamada, no la seguridad. + * + * Traduce automáticamente placeholders `?` (estilo SQLite) a `$1, $2, ...` + * (estilo Postgres). Todo el acceso es async (a diferencia de better-sqlite3 + * style), así que cada call-site necesita `await`. + */ + +export type PgRow = Record; + +function toPositional(text: string): string { + let i = 0; + return text.replace(/\?/g, () => `$${++i}`); +} + +export class PreparedStatement { + constructor(private sql: postgres.Sql, private text: string) {} + + async get(...params: unknown[]): Promise { + const rows = await this.sql.unsafe(toPositional(this.text), params as never[]); + return rows[0] as PgRow | undefined; + } + + async all(...params: unknown[]): Promise { + const rows = await this.sql.unsafe(toPositional(this.text), params as never[]); + return rows as unknown as PgRow[]; + } + + /** Igual que .all/.get pero no espera filas de vuelta (INSERT/UPDATE/DELETE). */ + async run(...params: unknown[]): Promise<{ changes: number }> { + const rows = await this.sql.unsafe(toPositional(this.text), params as never[]); + return { changes: rows.count ?? rows.length }; + } +} + +/** Wrapper con la forma db.prepare()/db.exec() de @db/sqlite, sobre una + * conexión (o conexión reservada) de postgres.js. */ +export class PgDb { + constructor(public raw: postgres.Sql) {} + + prepare(text: string): PreparedStatement { + return new PreparedStatement(this.raw, text); + } + + async exec(text: string): Promise { + await this.raw.unsafe(text); + } + + /** Equivalente a last_insert_rowid(): dentro de la MISMA conexión/ + * transacción, lastval() devuelve el último valor de secuencia obtenido + * en esta sesión. Solo es seguro si se llama justo después del INSERT + * correspondiente, en la misma conexión reservada por request. */ + async lastInsertId(): Promise { + const rows = await this.raw.unsafe("SELECT lastval()::bigint AS id"); + return Number((rows[0] as unknown as { id: number | bigint }).id); + } +} + +export function createPool(url: string, options: postgres.Options> = {}) { + return postgres(url, { + max: 10, + idle_timeout: 30, + connect_timeout: 10, + types: { + // postgres.js devuelve NUMERIC como string por defecto (evita perder + // precisión en valores gigantes, tipo BigDecimal). Aquí los montos + // son jornales/presupuestos -- ya vivían como REAL/float en SQLite, + // así que parseFloat no introduce una regresión de precisión nueva, + // y evita tener que tocar cada call-site que hace aritmética sobre + // columnas NUMERIC (daily_wage, gross, balance, amount, etc.). + numeric: { + to: 1700, + from: [1700], + serialize: (x: number) => String(x), + parse: (x: string) => Number.parseFloat(x), + }, + // postgres.js devuelve date/timestamp(tz) como objetos Date por + // defecto. Todo el código heredado de SQLite trata fechas como + // strings (comparaciones lexicográficas, .slice(0,10), template + // literals) -- se fuerzan a texto aquí para no reescribir cada + // call-site de fechas en el mismo cambio que separa las bases. + date: { to: 1082, from: [1082], serialize: (x: string) => x, parse: (x: string) => x }, + timestamp: { to: 1114, from: [1114], serialize: (x: string) => x, parse: (x: string) => x }, + timestamptz: { to: 1184, from: [1184], serialize: (x: string) => x, parse: (x: string) => x }, + // Todas las PK son BIGINT GENERATED ALWAYS AS IDENTITY (pensando en + // escala futura), pero postgres.js devuelve bigint como string por + // defecto (evita perder precisión más allá de Number.MAX_SAFE_INTEGER). + // A esta escala de negocio los ids nunca se acercan a ese límite, y + // el código heredado los trata como number en todas partes (Map, + // comparaciones ===, etc.), así que se parsean a Number aquí. + bigint: { + to: 20, + from: [20], + serialize: (x: number) => String(x), + parse: (x: string) => Number(x), + }, + }, + ...options, + }); +} + +/** + * Ejecuta `fn` dentro de una transacción con `app.tenant_id` fijado vía + * set_config(..., is_local=true) -- así las políticas de Row Level Security + * (db/iam/changesets/002-rls.sql, db/core/changesets/005-rls.sql) filtran + * automáticamente por tenant, y el valor se limpia solo al terminar la + * transacción sin importar qué conexión del pool se reutilice después. + * + * tenantId = null significa "sin tenant" (p. ej. platform_admin o la + * API key legacy): las políticas de RLS son fail-closed, así que sin + * tenant_id fijado NO se ve ninguna fila con tenant_id NOT NULL. Las + * rutas que de verdad necesitan cruzar tenants deben pasar por el rol de + * soporte explícito (ver Fase 4/nota de seguridad sobre el bypass de + * X-API-Key), no por dejar tenantId en null "por si acaso". + */ +export async function withTenant( + sql: postgres.Sql, + tenantId: number | null, + fn: (scoped: PgDb) => Promise, +): Promise { + const result = await sql.begin(async (tx) => { + if (tenantId != null) { + await tx`SELECT set_config('app.tenant_id', ${String(tenantId)}, true)`; + } + return [await fn(new PgDb(tx as unknown as postgres.Sql))] as const; + }); + return (result as unknown as [T])[0]; +} + +export type { postgres }; +export default postgres; diff --git a/api/platform_db.ts b/api/platform_db.ts index 33a3b76..72b2d94 100644 --- a/api/platform_db.ts +++ b/api/platform_db.ts @@ -1,13 +1,21 @@ -import { Database } from "@db/sqlite"; -import { mkdir } from "node:fs/promises"; -import { DATA_DIR, PLATFORM_DB_PATH } from "./config.ts"; +import postgres, { createPool, PgDb } from "./pg.ts"; import { config } from "./config.ts"; -import { hashPassword } from "./crypto.ts"; -import { runLiquibase } from "./liquibase.ts"; -export type PlatformDb = Database; +/** + * panels_platform: control plane SaaS (tenants, platform_users, + * smtp_settings). Base de datos separada de panels_product -- ningún + * código de iam/core debe importar este módulo, y viceversa. Sin RLS: no + * es multi-tenant en el mismo sentido (son las cuentas del propio equipo + * de PANELS), así que un solo pool basta. + * + * El bootstrap del primer platform_admin ya NO ocurre aquí en cada arranque + * (antes: seedPlatform() en cada getPlatformDb()) -- es un comando explícito + * y one-shot, ver scripts/bootstrap-admin.ts (Fase 4). + */ -let platformDb: Database | null = null; +let pool: postgres.Sql | null = null; + +export type PlatformDb = PgDb; export type PlatformUser = { id: number; @@ -16,70 +24,25 @@ export type PlatformUser = { status: string; }; -export async function getPlatformDb(): Promise { - if (platformDb) return platformDb; - await mkdir(DATA_DIR, { recursive: true }); - await runLiquibase(); - platformDb = new Database(PLATFORM_DB_PATH); - platformDb.exec("PRAGMA foreign_keys = ON;"); - await seedPlatform(platformDb); - return platformDb; +export async function getPlatformDb(): Promise { + if (!pool) { + pool = createPool(config.databaseUrlPlatform, { max: 10 }); + await pool`SELECT 1`; + } + return new PgDb(pool); } -async function seedPlatform(database: Database) { - if (!config.seedPassword) { - console.warn("[platform] SEED_PASSWORD vacío; no se siembra usuario admin"); - } else { - const hash = await hashPassword(config.seedPassword); - const admin = database.prepare("SELECT id FROM platform_users WHERE username = ?").get("admin") as - | { id: number } - | undefined; - const legacy = database.prepare("SELECT id FROM platform_users WHERE username = ?").get("operador") as - | { id: number } - | undefined; - - if (legacy && !admin) { - database.prepare( - `UPDATE platform_users - SET username = ?, password_hash = ?, display_name = ? - WHERE id = ?`, - ).run("admin", hash, "Admin PANELS", legacy.id); - } else if (!admin) { - database.prepare( - `INSERT INTO platform_users (username, password_hash, display_name, status) - VALUES (?, ?, ?, 'activo')`, - ).run("admin", hash, "Admin PANELS"); - } else if (legacy) { - database.prepare("DELETE FROM platform_users WHERE id = ?").run(legacy.id); - } - - const sync = ["1", "true", "yes"].includes( - (Deno.env.get("SEED_SYNC_PASSWORD") ?? "").toLowerCase(), - ); - if (sync) { - const row = database.prepare("SELECT id FROM platform_users WHERE username = ?").get("admin") as - | { id: number } - | undefined; - if (row) { - database.prepare( - `UPDATE platform_users SET password_hash = ?, display_name = ? WHERE id = ?`, - ).run(hash, "Admin PANELS", row.id); - console.warn("[platform] SEED_SYNC_PASSWORD: password de admin actualizado"); - } - } - } - const arctec = database.prepare( - "SELECT id FROM tenants WHERE code IN ('ARCT2608', 'ARCTEC')", - ).get(); - if (!arctec) { - database.prepare( - `INSERT INTO tenants (id, code, name, status) VALUES (1, 'ARCT2608', 'ARCTEC', 'activo')`, - ).run(); - } +export async function closePlatformDb(): Promise { + await pool?.end({ timeout: 5 }); + pool = null; } -export function listTenants(database: Database) { - return database.prepare( +export async function pingPlatformDb(): Promise { + await getPlatformDb().then((db) => db.prepare("SELECT 1").get()); +} + +export async function listTenants(database: PlatformDb) { + return await database.prepare( `SELECT id, code, name, status, created_at, COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes, COALESCE(contact_email, '') AS contact_email, @@ -101,8 +64,10 @@ export function listTenants(database: Database) { }[]; } -export function tenantByCode(database: Database, code: string) { - return database.prepare("SELECT * FROM tenants WHERE code = ?").get(code.trim().toUpperCase()) as +export async function tenantByCode(database: PlatformDb, code: string) { + return await database.prepare("SELECT * FROM tenants WHERE code = ?").get( + code.trim().toUpperCase(), + ) as | { id: number; code: string; @@ -115,8 +80,8 @@ export function tenantByCode(database: Database, code: string) { | undefined; } -export function tenantById(database: Database, id: number) { - return database.prepare("SELECT * FROM tenants WHERE id = ?").get(id) as +export async function tenantById(database: PlatformDb, id: number) { + return await database.prepare("SELECT * FROM tenants WHERE id = ?").get(id) as | { id: number; code: string; @@ -130,6 +95,6 @@ export function tenantById(database: Database, id: number) { | undefined; } -export function lastInsertId(database: Database): number { - return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); +export async function lastInsertId(database: PlatformDb): Promise { + return await database.lastInsertId(); } diff --git a/api/redis.ts b/api/redis.ts new file mode 100644 index 0000000..b51d956 --- /dev/null +++ b/api/redis.ts @@ -0,0 +1,51 @@ +import { createClient, type RedisClientType } from "npm:redis@4"; +import { config } from "./config.ts"; + +/** + * Dos clientes Redis separados, autenticados con usuarios ACL distintos + * (panels_iam_redis ~iam:*, panels_core_redis ~core:*, ver + * db/provision/05-redis-acl.sh). El prefijo de llave (`iam:`/`core:`) + * refuerza pero NO sustituye el ACL del servidor -- la separación real la + * da la credencial, no la convención de nombres. + * + * Redis no es fuente de verdad de nada aquí: sesiones (iam:session:*) se + * recuperan con un re-login; cache (core:cache:*) se recalcula desde + * Postgres. Por eso no hay lógica de reintento agresivo ni persistencia + * más allá de lo que ya ofrece el servidor. + */ + +let iamClient: RedisClientType | null = null; +let coreClient: RedisClientType | null = null; + +export async function getIamRedis(): Promise { + if (!iamClient) { + iamClient = createClient({ url: config.redisUrlIam }) as RedisClientType; + iamClient.on("error", (err) => console.error("[redis:iam]", err)); + await iamClient.connect(); + } + return iamClient; +} + +export async function getCoreRedis(): Promise { + if (!coreClient) { + coreClient = createClient({ url: config.redisUrlCore }) as RedisClientType; + coreClient.on("error", (err) => console.error("[redis:core]", err)); + await coreClient.connect(); + } + return coreClient; +} + +export async function pingRedis(): Promise<{ iam: boolean; core: boolean }> { + const [iam, core] = await Promise.all([ + getIamRedis().then((c) => c.ping()).then(() => true).catch(() => false), + getCoreRedis().then((c) => c.ping()).then(() => true).catch(() => false), + ]); + return { iam, core }; +} + +export async function closeRedis(): Promise { + await iamClient?.quit().catch(() => {}); + await coreClient?.quit().catch(() => {}); + iamClient = null; + coreClient = null; +} diff --git a/api/saas.ts b/api/saas.ts index f7dd69e..10b7647 100644 --- a/api/saas.ts +++ b/api/saas.ts @@ -1,6 +1,6 @@ -import type { Database } from "@db/sqlite"; import { generateSecurePassword, hashPassword } from "./crypto.ts"; -import { lastInsertId as appLastId } from "./db.ts"; +import { getCoreDb, type Db } from "./db.ts"; +import { withIamOwner } from "./iam_db.ts"; import { lastInsertId as platformLastId, listTenants as listTenantsRaw, @@ -107,29 +107,29 @@ export function codeDateStamp(when: Date = new Date()): string { return `${yy}${mm}`; } -function codeExists(platformDb: PlatformDb, appDb: Database, code: string): boolean { - return !!tenantByCode(platformDb, code) || !!companyByCode(appDb, code); +async function codeExists(platformDb: PlatformDb, coreDb: Db, code: string): Promise { + return !!await tenantByCode(platformDb, code) || !!await companyByCode(coreDb, code); } /** * Código único: prefijo del nombre comercial + YYMM de creación. * Ej. «ARCTEC» en ago-2026 → ARCT2608. Si choca, sufijo numérico. */ -export function allocateUniqueTenantCode( +export async function allocateUniqueTenantCode( platformDb: PlatformDb, - appDb: Database, + coreDb: Db, nombreComercial: string, createdAt: Date = new Date(), -): string { +): Promise { const base = shortCodeFromCommercialName(nombreComercial); const stamp = codeDateStamp(createdAt); const primary = `${base}${stamp}`; - if (!codeExists(platformDb, appDb, primary)) return primary; + if (!await codeExists(platformDb, coreDb, primary)) return primary; for (let n = 2; n < 1000; n++) { const suffix = String(n); const head = base.slice(0, Math.max(2, 4 - suffix.length)); const candidate = `${head}${stamp}${suffix}`; - if (candidate.length <= 16 && !codeExists(platformDb, appDb, candidate)) return candidate; + if (candidate.length <= 16 && !await codeExists(platformDb, coreDb, candidate)) return candidate; } return `E${stamp}${Date.now().toString(36).toUpperCase().slice(-4)}`; } @@ -178,16 +178,16 @@ export function requireSaasCompanyFields( return null; } -export function listTenants(platformDb: PlatformDb) { - return listTenantsRaw(platformDb); +export async function listTenants(platformDb: PlatformDb) { + return await listTenantsRaw(platformDb); } -export function getTenantDetail( +export async function getTenantDetail( platformDb: PlatformDb, - appDb: Database, + coreDb: Db, tenantId: number, -): TenantDetail | null { - const t = platformDb.prepare( +): Promise { + const t = await platformDb.prepare( `SELECT id, code, name, status, created_at, COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes, COALESCE(contact_email, '') AS contact_email, @@ -211,7 +211,11 @@ export function getTenantDetail( | undefined; if (!t) return null; - const company = appDb.prepare( + // company/admins viven en core/iam -- este lookup administrativo cruza + // tenants a propósito (consola SaaS viendo el detalle de UN tenant desde + // fuera de cualquier sesión de ese tenant), así que coreDb debe ser la + // conexión owner (bypassa RLS), no una conexión scoped por tenant. + const company = await coreDb.prepare( `SELECT id, code, name, COALESCE(rfc, '') AS rfc, COALESCE(razon_social, '') AS razon_social, @@ -230,20 +234,23 @@ export function getTenantDetail( FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`, ).get(tenantId) as CompanySnapshot | undefined; - const admins = (appDb.prepare( - `SELECT id, username, display_name, COALESCE(email, '') AS email, role, - COALESCE(must_change_password, 0) AS must_change_password, created_at - FROM users WHERE tenant_id = ? AND role = 'tenant_admin' - ORDER BY id`, - ).all(tenantId) as Array>).map((a) => ({ - id: Number(a.id), - username: String(a.username), - display_name: String(a.display_name), - email: String(a.email || ""), - role: String(a.role), - must_change_password: Boolean(a.must_change_password), - created_at: String(a.created_at), - })); + const admins = await withIamOwner(async (iam) => { + const rows = await iam.prepare( + `SELECT id, username, display_name, COALESCE(email, '') AS email, role_code AS role, + COALESCE(must_change_password, false) AS must_change_password, created_at + FROM users WHERE tenant_id = ? AND role_code = 'tenant_admin' + ORDER BY id`, + ).all(tenantId) as Array>; + return rows.map((a) => ({ + id: Number(a.id), + username: String(a.username), + display_name: String(a.display_name), + email: String(a.email || ""), + role: String(a.role), + must_change_password: Boolean(a.must_change_password), + created_at: String(a.created_at), + })); + }); return { ...t, @@ -252,9 +259,19 @@ export function getTenantDetail( }; } +/** + * Alta de tenant: cruza panels_platform (tenants) + panels_product.core + * (companies) + panels_product.iam (users), tres bases/esquemas sin + * transacción distribuida posible entre ellos (Fase 5b del plan). Se + * implementa como compensación explícita: si algo falla después de crear + * el tenant y/o la empresa, se borra lo ya creado antes de devolver el + * error -- así un fallo a medio alta nunca deja un tenant sin empresa/admin + * o una empresa sin tenant. Es idempotente por `code`: si el caller + * reintenta tras un error ya limpiado, simplemente crea todo de nuevo. + */ export async function createTenant( platformDb: PlatformDb, - appDb: Database, + coreDb: Db, input: CreateTenantInput, ): Promise<{ tenant?: { id: number; code: string; name: string; status: string }; @@ -271,7 +288,7 @@ export async function createTenant( }); if (completeErr) return { error: completeErr }; - const code = allocateUniqueTenantCode(platformDb, appDb, profile.nombre_comercial); + const code = await allocateUniqueTenantCode(platformDb, coreDb, profile.nombre_comercial); const displayName = profile.nombre_comercial; const adminUser = adminUsernameForCode(code); @@ -279,7 +296,7 @@ export async function createTenant( const contactEmail = profile.email; const adminName = trim(input.admin_display_name) || contactName; - const userClash = appDb.prepare("SELECT id FROM users WHERE username = ?").get(adminUser); + const userClash = await withIamOwner((iam) => iam.prepare("SELECT id FROM users WHERE username = ?").get(adminUser)); if (userClash) return { error: `Ya existe el usuario ${adminUser}` }; const plan = trim(input.plan) || "trial"; @@ -288,53 +305,65 @@ export async function createTenant( const notes = trim(input.notes); const temporaryPassword = generateSecurePassword(); - platformDb.prepare( + // Paso 1: tenant en panels_platform. + await platformDb.prepare( `INSERT INTO tenants (code, name, status, plan, valid_until, notes, contact_email, contact_name) VALUES (?, ?, 'activo', ?, ?, ?, ?, ?)`, ).run(code, displayName, plan, validUntil, notes, contactEmail, contactName); - const tenantId = platformLastId(platformDb); + const tenantId = await platformLastId(platformDb); + let companyId: number | undefined; try { - appDb.prepare( - `INSERT INTO companies ( - code, name, parent_id, kind, status, tenant_id, - razon_social, nombre_comercial, rfc, regimen_fiscal, registro_patronal, clase_riesgo, - domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro - ) VALUES (?, ?, NULL, 'principal', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - code, - displayName, - tenantId, - profile.razon_social, - profile.nombre_comercial, - profile.rfc, - profile.regimen_fiscal, - profile.registro_patronal, - profile.clase_riesgo, - profile.domicilio_fiscal, - profile.codigo_postal, - profile.ciudad, - profile.estado, - profile.telefono, - contactEmail, - profile.representante_legal, - profile.giro, + // Paso 2: empresa principal en core (rol owner: alta administrativa + // cruzando tenants, no una operación de un usuario ya autenticado). + companyId = await getCoreDb().then((core) => + core.prepare( + `INSERT INTO companies ( + code, name, parent_id, kind, status, tenant_id, + razon_social, nombre_comercial, rfc, regimen_fiscal, registro_patronal, clase_riesgo, + domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro + ) VALUES (?, ?, NULL, 'principal', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + code, + displayName, + tenantId, + profile.razon_social, + profile.nombre_comercial, + profile.rfc, + profile.regimen_fiscal, + profile.registro_patronal, + profile.clase_riesgo, + profile.domicilio_fiscal, + profile.codigo_postal, + profile.ciudad, + profile.estado, + profile.telefono, + contactEmail, + profile.representante_legal, + profile.giro, + ).then(() => core.lastInsertId()) ); - const companyId = appLastId(appDb); + // Paso 3: admin del tenant en iam. const hash = await hashPassword(temporaryPassword); - appDb.prepare( - `INSERT INTO users ( - username, password_hash, display_name, company_id, tenant_id, role, - must_change_password, email - ) VALUES (?, ?, ?, ?, ?, 'tenant_admin', 1, ?)`, - ).run(adminUser, hash, adminName, companyId, tenantId, contactEmail); + await withIamOwner((iam) => + iam.prepare( + `INSERT INTO users ( + username, password_hash, display_name, company_id, tenant_id, role_code, + must_change_password, email + ) VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, ?)`, + ).run(adminUser, hash, adminName, companyId, tenantId, contactEmail) + ); } catch (e) { - platformDb.prepare("DELETE FROM tenants WHERE id = ?").run(tenantId); + // Compensación: limpiar en orden inverso lo que sí se alcanzó a crear. + if (companyId != null) { + await getCoreDb().then((core) => core.prepare("DELETE FROM companies WHERE id = ?").run(companyId)); + } + await platformDb.prepare("DELETE FROM tenants WHERE id = ?").run(tenantId); return { error: e instanceof Error ? e.message : "Error al crear empresa" }; } - const tenant = platformDb.prepare( + const tenant = await platformDb.prepare( "SELECT id, code, name, status FROM tenants WHERE id = ?", ).get(tenantId) as { id: number; code: string; name: string; status: string }; @@ -359,16 +388,16 @@ export async function createTenant( }; } -export function updateTenant( +export async function updateTenant( platformDb: PlatformDb, - appDb: Database, + coreDb: Db, tenantId: number, input: UpdateTenantInput, -): { error?: string } { - const current = tenantById(platformDb, tenantId); +): Promise<{ error?: string }> { + const current = await tenantById(platformDb, tenantId); if (!current) return { error: "Empresa no encontrada" }; - const company = appDb.prepare( + const company = await coreDb.prepare( `SELECT * FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`, ).get(tenantId) as Record | undefined; @@ -398,7 +427,7 @@ export function updateTenant( }; const profile = normalizeCompanyProfile(merged, current.name); - const row = platformDb.prepare( + const row = await platformDb.prepare( `SELECT COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes, COALESCE(contact_email, '') AS contact_email, COALESCE(contact_name, '') AS contact_name FROM tenants WHERE id = ?`, @@ -450,13 +479,13 @@ export function updateTenant( const displayName = profile.nombre_comercial || profile.razon_social || current.name; - platformDb.prepare( + await platformDb.prepare( `UPDATE tenants SET name = ?, status = ?, plan = ?, valid_until = ?, notes = ?, contact_email = ?, contact_name = ? WHERE id = ?`, ).run(displayName, status, plan, validUntil, notes, contactEmail, contactName, tenantId); if (company?.id) { - appDb.prepare( + await coreDb.prepare( `UPDATE companies SET name = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, registro_patronal = ?, clase_riesgo = ?, domicilio_fiscal = ?, codigo_postal = ?, @@ -488,7 +517,7 @@ export function updateTenant( /** Regenera contraseña del admin, marca must_change_password y opcionalmente envía correo. */ export async function issueTenantAdminAccess( platformDb: PlatformDb, - appDb: Database, + coreDb: Db, tenantId: number, opts: { userId?: number; send_email?: boolean } = {}, ): Promise<{ @@ -496,7 +525,7 @@ export async function issueTenantAdminAccess( email?: { sent: boolean; error?: string }; error?: string; }> { - const detail = getTenantDetail(platformDb, appDb, tenantId); + const detail = await getTenantDetail(platformDb, coreDb, tenantId); if (!detail) return { error: "Empresa no encontrada" }; const admin = opts.userId ? detail.admins.find((a) => a.id === opts.userId) @@ -505,9 +534,11 @@ export async function issueTenantAdminAccess( const temporaryPassword = generateSecurePassword(); const hash = await hashPassword(temporaryPassword); - appDb.prepare( - `UPDATE users SET password_hash = ?, must_change_password = 1 WHERE id = ? AND tenant_id = ?`, - ).run(hash, admin.id, tenantId); + await withIamOwner((iam) => + iam.prepare( + `UPDATE users SET password_hash = ?, must_change_password = true WHERE id = ? AND tenant_id = ?`, + ).run(hash, admin.id, tenantId) + ); const contactEmail = detail.contact_email || admin.email; let emailResult: { sent: boolean; error?: string } = { sent: false }; @@ -565,16 +596,16 @@ async function sendAccessEmail( attachments: accessEmailAttachments(), }); if (result.sent) { - platformDb.prepare( - `UPDATE tenants SET access_sent_at = datetime('now') WHERE id = ?`, + await platformDb.prepare( + `UPDATE tenants SET access_sent_at = now() WHERE id = ?`, ).run(opts.tenantId); } return result; } -export function tenantAccessBlocked(platformDb: PlatformDb, tenantId: number | null): string | null { +export async function tenantAccessBlocked(platformDb: PlatformDb, tenantId: number | null): Promise { if (tenantId == null) return "Cuenta sin empresa asignada"; - const t = platformDb.prepare( + const t = await platformDb.prepare( `SELECT status, valid_until FROM tenants WHERE id = ?`, ).get(tenantId) as { status: string; valid_until: string | null } | undefined; if (!t) return "Empresa no encontrada"; diff --git a/api/scope.ts b/api/scope.ts new file mode 100644 index 0000000..dffa853 --- /dev/null +++ b/api/scope.ts @@ -0,0 +1,26 @@ +import type { Context, Next } from "hono"; +import type { AuthUser } from "./auth.ts"; +import { requireAuth, tenantScope } from "./auth.ts"; +import { withCoreTenant } from "./db.ts"; + +/** + * Middleware que abre la transacción de Postgres con app.tenant_id fijado + * (Row Level Security) para toda la duración del request, y la expone en + * `c.get("db")`. Encadenar SIEMPRE después de requireAuth en las rutas de + * `core` (workers/proyectos/empresas/presupuesto/nómina/documentos/gafetes). + * + * Esto es lo que hace que la Fase 4b (RLS) realmente proteja algo: sin + * este middleware, ninguna conexión tendría app.tenant_id fijado y las + * políticas fail-closed devolverían cero filas para todo. + */ +export async function withCoreScope(c: Context, next: Next) { + const user = c.get("user") as AuthUser; + const tid = tenantScope(user); + await withCoreTenant(tid, async (db) => { + c.set("db", db); + await next(); + }); +} + +/** Atajo para registrar rutas de `core`: `app.get(path, ...requireCoreAuth, handler)`. */ +export const requireCoreAuth = [requireAuth, withCoreScope] as const; diff --git a/api/scripts/bootstrap-admin.ts b/api/scripts/bootstrap-admin.ts new file mode 100644 index 0000000..4a44496 --- /dev/null +++ b/api/scripts/bootstrap-admin.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env -S deno run --allow-net --allow-env --allow-read +/** + * PANELS · Fase 4 · bootstrap explícito y one-shot del primer administrador. + * + * Antes, `seed()` (api/db.ts) y `seedPlatform()` (api/platform_db.ts) + * creaban/actualizaban contraseñas de admin en CADA arranque de la API + * (incluyendo el peligroso SEED_SYNC_PASSWORD reescribiendo passwords en + * cada deploy). Ahora es un comando manual, corrido una vez por ambiente. + * + * Uso (correr SIEMPRE desde dentro de api/, para que se resuelva + * api/deno.json y sus dependencias npm): + * cd api + * set -a && source ../.env.dev-local && set +a + * deno run --allow-net --allow-env --allow-read scripts/bootstrap-admin.ts platform + * deno run --allow-net --allow-env --allow-read scripts/bootstrap-admin.ts tenant \ + * --tenant-id=1 --company-code=ARCT2608 --username=arct2608 --display-name="Administrador" + * + * Requiere SEED_PASSWORD en el entorno. No falla si el usuario ya existe: + * actualiza el hash y deja must_change_password=true. + */ +import { getPlatformDb } from "../platform_db.ts"; +import { getCoreDb } from "../db.ts"; +import { withIamOwner } from "../iam_db.ts"; +import { hashPassword } from "../crypto.ts"; +import { config } from "../config.ts"; + +function arg(name: string, fallback?: string): string | undefined { + const prefix = `--${name}=`; + const found = Deno.args.find((a) => a.startsWith(prefix)); + return found ? found.slice(prefix.length) : fallback; +} + +async function bootstrapPlatformAdmin() { + if (!config.seedPassword) { + throw new Error("Falta SEED_PASSWORD en el entorno"); + } + const pdb = await getPlatformDb(); + const hash = await hashPassword(config.seedPassword); + const existing = await pdb.prepare( + "SELECT id FROM platform_users WHERE username = 'admin'", + ).get(); + if (existing) { + await pdb.prepare( + "UPDATE platform_users SET password_hash = ?, display_name = 'Admin PANELS', status = 'activo' WHERE username = 'admin'", + ).run(hash); + console.log("[bootstrap] platform_users.admin actualizado"); + } else { + await pdb.prepare( + `INSERT INTO platform_users (username, password_hash, display_name, status) + VALUES ('admin', ?, 'Admin PANELS', 'activo')`, + ).run(hash); + console.log("[bootstrap] platform_users.admin creado"); + } +} + +async function bootstrapTenantAdmin() { + if (!config.seedPassword) { + throw new Error("Falta SEED_PASSWORD en el entorno"); + } + const tenantId = Number(arg("tenant-id", "1")); + const companyCode = arg("company-code", "ARCT2608")!; + const username = arg("username", "arct2608")!; + const displayName = arg("display-name", "Administrador")!; + + const core = await getCoreDb(); + const company = await core.prepare( + "SELECT id FROM companies WHERE code = ? AND tenant_id = ?", + ).get(companyCode, tenantId) as { id: number } | undefined; + if (!company) { + throw new Error( + `No existe la empresa ${companyCode} en tenant_id=${tenantId} -- corre primero las migraciones con --context-filter=dev`, + ); + } + + const hash = await hashPassword(config.seedPassword); + await withIamOwner(async (db) => { + const existing = await db.prepare("SELECT id FROM users WHERE username = ?").get(username); + if (existing) { + await db.prepare( + `UPDATE users SET password_hash = ?, display_name = ?, company_id = ?, tenant_id = ?, + role_code = 'tenant_admin', must_change_password = true WHERE username = ?`, + ).run(hash, displayName, company.id, tenantId, username); + console.log(`[bootstrap] iam.users.${username} actualizado (tenant_id=${tenantId})`); + } else { + await db.prepare( + `INSERT INTO users (username, password_hash, display_name, company_id, tenant_id, role_code, must_change_password, email) + VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, '')`, + ).run(username, hash, displayName, company.id, tenantId); + console.log(`[bootstrap] iam.users.${username} creado (tenant_id=${tenantId})`); + } + }); +} + +const mode = Deno.args[0]; +if (mode === "platform") { + await bootstrapPlatformAdmin(); +} else if (mode === "tenant") { + await bootstrapTenantAdmin(); +} else { + console.error("Uso: bootstrap-admin.ts [--tenant-id=1] [--company-code=ARCT2608] [--username=arct2608] [--display-name=Administrador]"); + Deno.exit(1); +} +Deno.exit(0); diff --git a/api/seed_lista.ts b/api/seed_lista.ts index 1c602c1..c4c44e0 100644 --- a/api/seed_lista.ts +++ b/api/seed_lista.ts @@ -1,9 +1,14 @@ /** * Carga LISTA COLABORADORES GAFETES.xlsx al padrón para pruebas. * Completa CURP/RFC/NSS/contacto faltantes con valores válidos únicos. + * + * Uso: set -a && source ../.env.dev-local && set +a + * deno run --allow-net --allow-env --allow-read scripts/seed_lista.ts + * + * Corre contra un tenant fijo (por defecto 1); ajustar TENANT_ID si hace falta. */ import * as XLSX from "xlsx"; -import { getDb } from "./db.ts"; +import { withCoreTenant } from "./db.ts"; import { upsertWorker, storeDocument } from "./excel.ts"; import { resolveCompany } from "./companies.ts"; import { @@ -16,7 +21,8 @@ import { type WorkerInput, } from "./mx.ts"; -const FILE = "/mnt/c/Users/betom/Downloads/LISTA COLABORADORES GAFETES.xlsx"; +const FILE = Deno.env.get("SEED_LISTA_FILE") ?? "/mnt/c/Users/betom/Downloads/LISTA COLABORADORES GAFETES.xlsx"; +const TENANT_ID = Number(Deno.env.get("SEED_LISTA_TENANT_ID") ?? "1"); const CONS = "BCDFGHJKLMNPQRSTVWXYZ"; function cell(r: Record, k: string) { @@ -68,81 +74,82 @@ async function fetchPhoto(url: string): Promise { } } -const wb = XLSX.read(await Deno.readFile(FILE), { type: "array" }); -const db = await getDb(); -const project = db.prepare("SELECT id FROM projects ORDER BY id LIMIT 1").get() as { id: number } | undefined; -const risks = new Set((db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((x) => x.code)); +await withCoreTenant(TENANT_ID, async (db) => { + const wb = XLSX.read(await Deno.readFile(FILE), { type: "array" }); + const project = await db.prepare("SELECT id FROM projects ORDER BY id LIMIT 1").get() as { id: number } | undefined; + const risks = new Set((await db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((x) => x.code)); -type Job = { sheet: string; status: "activo" | "baja" }; -const jobs: Job[] = [ - { sheet: "ACTUALES", status: "activo" }, - { sheet: "HISTORICO", status: "baja" }, -]; + type Job = { sheet: string; status: "activo" | "baja" }; + const jobs: Job[] = [ + { sheet: "ACTUALES", status: "activo" }, + { sheet: "HISTORICO", status: "baja" }, + ]; -let inserted = 0, existed = 0, skipped = 0, photos = 0, i = 0; + let inserted = 0, existed = 0, skipped = 0, photos = 0, i = 0; -for (const job of jobs) { - const rows = XLSX.utils.sheet_to_json>(wb.Sheets[job.sheet], { defval: "" }); - for (const raw of rows) { - const first = cell(raw, "NOMBRE"); - const lastp = cell(raw, "APELLIDO PATERNO"); - if (!first || !lastp) { - skipped++; - continue; - } - i++; - let curp = cell(raw, "CURP"); - let rfc = cell(raw, "RFC"); - let nss = cell(raw, "NSS"); - if (validateCurp(curp)) curp = makeCurp(i); - if (validateRfc(rfc)) rfc = makeRfc(curp || makeCurp(i), i); - if (validateNss(nss)) nss = makeNss(1000 + i); - const ids = { curp, rfc, nss }; - const risk = canonicalRiskCode(cell(raw, "RIESGO")); - const hire = cell(raw, "ALTA").toUpperCase(); - const input: WorkerInput = { - first_name: first, - middle_name: cell(raw, "2 NOMBRE") || null, - last_name_p: lastp, - last_name_m: cell(raw, "APELLIDO MATERNO") || "X", - curp: ids.curp, - rfc: ids.rfc, - nss: ids.nss, - phone: cell(raw, "TELEFONO") || String(9981000000 + i), - email: cell(raw, "CORREO") || `${slug(first)}.${slug(lastp)}.${i}@pruebas.arctec.local`, - address: cell(raw, "DIRECCION") || "Sin domicilio en lista original", - blood_type: cell(raw, "TIPO SANGRE") || null, - hire_type: hire || "ARCT2608", - position: cell(raw, "CARGO") || "AYUDANTE", - risk_code: risks.has(risk) ? risk : "rojo", - work_type: cell(raw, "TIPO TRABAJO").toUpperCase() === "D" ? "D" : "N", - daily_wage: 450, - needs_badge: ["si", "sí", "yes"].includes(cell(raw, "GAFETE").toLowerCase()), - status: job.status, - }; - const errors = validateWorkerFields(input); - const company = resolveCompany(db, input) ?? resolveCompany(db, { hire_type: "ARCT2608" }); - if (!company) errors.hire_type = "Empresa no encontrada"; - if (Object.keys(errors).length || !company) { - console.log("SKIP", first, lastp, errors); - skipped++; - continue; - } - const n = { ...normalizeWorker(input), hire_type: company.code, company_id: company.id }; - const res = upsertWorker(db, n, job.status === "activo" ? project?.id ?? null : null); - if (res.action === "inserted") inserted++; - else existed++; - const url = cell(raw, "URL FOTO"); - if (url) { - const photo = await fetchPhoto(url); - if (photo) { - const mime = photo[0] === 0xff ? "image/jpeg" : "image/png"; - await storeDocument(db, res.id, "foto", "foto-lista.jpg", mime, photo, 1); - photos++; + for (const job of jobs) { + const rows = XLSX.utils.sheet_to_json>(wb.Sheets[job.sheet], { defval: "" }); + for (const raw of rows) { + const first = cell(raw, "NOMBRE"); + const lastp = cell(raw, "APELLIDO PATERNO"); + if (!first || !lastp) { + skipped++; + continue; + } + i++; + let curp = cell(raw, "CURP"); + let rfc = cell(raw, "RFC"); + let nss = cell(raw, "NSS"); + if (validateCurp(curp)) curp = makeCurp(i); + if (validateRfc(rfc)) rfc = makeRfc(curp || makeCurp(i), i); + if (validateNss(nss)) nss = makeNss(1000 + i); + const ids = { curp, rfc, nss }; + const risk = canonicalRiskCode(cell(raw, "RIESGO")); + const hire = cell(raw, "ALTA").toUpperCase(); + const input: WorkerInput = { + first_name: first, + middle_name: cell(raw, "2 NOMBRE") || null, + last_name_p: lastp, + last_name_m: cell(raw, "APELLIDO MATERNO") || "X", + curp: ids.curp, + rfc: ids.rfc, + nss: ids.nss, + phone: cell(raw, "TELEFONO") || String(9981000000 + i), + email: cell(raw, "CORREO") || `${slug(first)}.${slug(lastp)}.${i}@pruebas.arctec.local`, + address: cell(raw, "DIRECCION") || "Sin domicilio en lista original", + blood_type: cell(raw, "TIPO SANGRE") || null, + hire_type: hire || "ARCT2608", + position: cell(raw, "CARGO") || "AYUDANTE", + risk_code: risks.has(risk) ? risk : "rojo", + work_type: cell(raw, "TIPO TRABAJO").toUpperCase() === "D" ? "D" : "N", + daily_wage: 450, + needs_badge: ["si", "sí", "yes"].includes(cell(raw, "GAFETE").toLowerCase()), + status: job.status, + }; + const errors = validateWorkerFields(input); + const company = (await resolveCompany(db, input)) ?? (await resolveCompany(db, { hire_type: "ARCT2608" })); + if (!company) errors.hire_type = "Empresa no encontrada"; + if (Object.keys(errors).length || !company) { + console.log("SKIP", first, lastp, errors); + skipped++; + continue; + } + const n = { ...normalizeWorker(input), hire_type: company.code, company_id: company.id, tenant_id: TENANT_ID }; + const res = await upsertWorker(db, n, job.status === "activo" ? project?.id ?? null : null); + if (res.action === "inserted") inserted++; + else existed++; + const url = cell(raw, "URL FOTO"); + if (url) { + const photo = await fetchPhoto(url); + if (photo) { + const mime = photo[0] === 0xff ? "image/jpeg" : "image/png"; + await storeDocument(db, res.id, "foto", "foto-lista.jpg", mime, photo, null); + photos++; + } } } } -} -const total = db.prepare("SELECT COUNT(*) AS n FROM workers").get() as { n: number }; -console.log(JSON.stringify({ inserted, existed, skipped, photos, workers_in_db: total.n }, null, 2)); + const total = await db.prepare("SELECT COUNT(*) AS n FROM workers").get() as { n: number }; + console.log(JSON.stringify({ inserted, existed, skipped, photos, workers_in_db: total.n }, null, 2)); +}); diff --git a/api/sessions.ts b/api/sessions.ts new file mode 100644 index 0000000..5a0530c --- /dev/null +++ b/api/sessions.ts @@ -0,0 +1,98 @@ +import { getIamRedis } from "./redis.ts"; + +/** + * Sesiones en Redis (Fase 4): la cookie po_session ya no es un payload + * autocontenido firmado con HMAC -- es un id opaco. El estado real vive en + * `iam:session:` (hash con TTL) y hay un índice secundario + * `iam:user_sessions:` (set de ids) para poder revocar TODAS las + * sesiones de un usuario de un golpe (logout real, cambio de password, + * bloqueo de tenant) -- algo que el diseño anterior (HMAC stateless) no + * podía hacer sin esperar a que expirara la cookie. + */ + +export type SessionRealm = "app" | "platform"; + +export type SessionData = { + userId: number; + realm: SessionRealm; + tenantId: number | null; + issuedAt: number; +}; + +const TTL_SECONDS = 60 * 60 * 24 * 7; // 7 días, igual que el diseño anterior + +function sessionKey(id: string): string { + return `iam:session:${id}`; +} + +function userSessionsKey(userId: number, realm: SessionRealm): string { + return `iam:user_sessions:${realm}:${userId}`; +} + +function randomId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll( + "=", + "", + ); +} + +export async function createSession( + userId: number, + realm: SessionRealm, + tenantId: number | null, +): Promise { + const redis = await getIamRedis(); + const id = randomId(); + await redis.hSet(sessionKey(id), { + userId: String(userId), + realm, + tenantId: tenantId == null ? "" : String(tenantId), + issuedAt: String(Date.now()), + }); + await redis.expire(sessionKey(id), TTL_SECONDS); + await redis.sAdd(userSessionsKey(userId, realm), id); + return id; +} + +export async function getSession(id: string): Promise { + if (!id) return null; + const redis = await getIamRedis(); + const raw = await redis.hGetAll(sessionKey(id)); + if (!raw || Object.keys(raw).length === 0) return null; + // Refresca el TTL en cada acceso (sesión "deslizante", igual comportamiento + // que la cookie de 7 días anterior, que se renovaba en cada login). + await redis.expire(sessionKey(id), TTL_SECONDS); + return { + userId: Number(raw.userId), + realm: raw.realm === "platform" ? "platform" : "app", + tenantId: raw.tenantId ? Number(raw.tenantId) : null, + issuedAt: Number(raw.issuedAt), + }; +} + +export async function revokeSession(id: string): Promise { + if (!id) return; + const redis = await getIamRedis(); + const raw = await redis.hGetAll(sessionKey(id)); + await redis.del(sessionKey(id)); + if (raw?.userId) { + const realm: SessionRealm = raw.realm === "platform" ? "platform" : "app"; + await redis.sRem(userSessionsKey(Number(raw.userId), realm), id); + } +} + +/** Revoca TODAS las sesiones activas de un usuario -- logout real al + * cambiar password o al bloquear/suspender su tenant. */ +export async function revokeAllSessionsForUser( + userId: number, + realm: SessionRealm, +): Promise { + const redis = await getIamRedis(); + const key = userSessionsKey(userId, realm); + const ids = await redis.sMembers(key); + if (ids.length) { + await redis.del(ids.map(sessionKey)); + } + await redis.del(key); +} diff --git a/api/smtp.ts b/api/smtp.ts index 19baf48..0fdfce9 100644 --- a/api/smtp.ts +++ b/api/smtp.ts @@ -29,7 +29,7 @@ type SmtpRow = { username: string; password: string; from_address: string; - enabled: number; + enabled: boolean; updated_at: string; }; @@ -48,9 +48,9 @@ function fromEnv(): SmtpSettings { }; } -function readRow(platformDb: PlatformDb): SmtpRow | undefined { +async function readRow(platformDb: PlatformDb): Promise { try { - return platformDb.prepare( + return await platformDb.prepare( `SELECT host, port, username, password, from_address, enabled, updated_at FROM smtp_settings WHERE id = 1`, ).get() as SmtpRow | undefined; @@ -59,9 +59,11 @@ function readRow(platformDb: PlatformDb): SmtpRow | undefined { } } -/** Config efectiva: fila en platform.db si hay host; si no, variables de entorno. */ -export function resolveSmtp(platformDb: PlatformDb): SmtpSettings & { source: "db" | "env" | "none" } { - const row = readRow(platformDb); +/** Config efectiva: fila en panels_platform si hay host; si no, variables de entorno. */ +export async function resolveSmtp( + platformDb: PlatformDb, +): Promise { + const row = await readRow(platformDb); if (row && trim(row.host)) { return { host: trim(row.host), @@ -69,7 +71,7 @@ export function resolveSmtp(platformDb: PlatformDb): SmtpSettings & { source: "d username: trim(row.username), password: row.password ?? "", from_address: trim(row.from_address) || config.smtpFrom, - enabled: Number(row.enabled) === 1, + enabled: Boolean(row.enabled), updated_at: row.updated_at, source: "db", }; @@ -79,20 +81,20 @@ export function resolveSmtp(platformDb: PlatformDb): SmtpSettings & { source: "d return { ...env, enabled: false, source: "none" }; } -export function smtpConfigured(platformDb: PlatformDb): boolean { - const s = resolveSmtp(platformDb); +export async function smtpConfigured(platformDb: PlatformDb): Promise { + const s = await resolveSmtp(platformDb); return s.enabled && Boolean(s.host && s.from_address); } -export function smtpPublicView(platformDb: PlatformDb): SmtpPublic { - const s = resolveSmtp(platformDb); +export async function smtpPublicView(platformDb: PlatformDb): Promise { + const s = await resolveSmtp(platformDb); return { host: s.host, port: s.port, username: s.username, from_address: s.from_address, enabled: s.enabled, - configured: smtpConfigured(platformDb), + configured: await smtpConfigured(platformDb), has_password: Boolean(s.password), updated_at: s.updated_at ?? null, source: s.source, @@ -115,10 +117,10 @@ function looksLikeEmailFrom(value: string): boolean { return /(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(value); } -export function saveSmtpSettings( +export async function saveSmtpSettings( platformDb: PlatformDb, input: SaveSmtpInput, -): { error?: string; settings?: SmtpPublic } { +): Promise<{ error?: string; settings?: SmtpPublic }> { const host = trim(input.host); const fromAddress = trim(input.from_address); const username = trim(input.username); @@ -136,15 +138,15 @@ export function saveSmtpSettings( }; } - const current = readRow(platformDb); + const current = await readRow(platformDb); let password = input.password ?? ""; if ((input.keep_password || password === "") && current?.password) { password = current.password; } - platformDb.prepare( + await platformDb.prepare( `INSERT INTO smtp_settings (id, host, port, username, password, from_address, enabled, updated_at) - VALUES (1, ?, ?, ?, ?, ?, ?, datetime('now')) + VALUES (1, ?, ?, ?, ?, ?, ?, now()) ON CONFLICT(id) DO UPDATE SET host = excluded.host, port = excluded.port, @@ -152,8 +154,8 @@ export function saveSmtpSettings( password = excluded.password, from_address = excluded.from_address, enabled = excluded.enabled, - updated_at = datetime('now')`, - ).run(host, port, username, password, fromAddress, enabled ? 1 : 0); + updated_at = now()`, + ).run(host, port, username, password, fromAddress, enabled); - return { settings: smtpPublicView(platformDb) }; + return { settings: await smtpPublicView(platformDb) }; } diff --git a/api/storage.ts b/api/storage.ts new file mode 100644 index 0000000..0563039 --- /dev/null +++ b/api/storage.ts @@ -0,0 +1,100 @@ +import { S3Client, PutObjectCommand, GetObjectCommand } from "npm:@aws-sdk/client-s3@3"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { config, DATA_DIR } from "./config.ts"; + +/** + * Almacenamiento de archivos (Fase 4c): expedientes cifrados, PDFs de + * gafetes, logos. Antes vivían en el volumen local (panel-data) -- un + * volumen local no se comparte entre réplicas ni microservicios, así que + * es el mismo cuello de botella que motivó salir de SQLite, solo que en + * otra capa. El contenido YA viene cifrado con AES-GCM (docs_crypto.ts) + * antes de llegar aquí -- el bucket nunca ve texto plano. + * + * Backend real: Contabo Object Storage (S3-compatible). Si no hay + * credenciales S3 configuradas (dev local sin Contabo), cae a disco local + * bajo DATA_DIR -- útil para desarrollar/probar sin depender de Contabo, + * pero NO es la ruta recomendada para staging/producción (ver Fase 4c del + * plan: un volumen local no escala horizontalmente). + */ + +let client: S3Client | null = null; + +function s3Configured(): boolean { + return !!(config.s3Endpoint && config.s3Bucket && config.s3AccessKeyId && config.s3SecretAccessKey); +} + +function getClient(): S3Client { + if (!client) { + client = new S3Client({ + endpoint: config.s3Endpoint, + region: config.s3Region, + forcePathStyle: true, + credentials: { + accessKeyId: config.s3AccessKeyId, + secretAccessKey: config.s3SecretAccessKey, + }, + }); + } + return client; +} + +async function streamToUint8Array(body: unknown): Promise { + const chunks: Uint8Array[] = []; + // deno-lint-ignore no-explicit-any + for await (const chunk of body as any) { + chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)); + } + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; +} + +export async function putObject(key: string, bytes: Uint8Array): Promise { + if (s3Configured()) { + await getClient().send( + new PutObjectCommand({ Bucket: config.s3Bucket, Key: key, Body: bytes }), + ); + return; + } + const path = join(DATA_DIR, "local-objects", key); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes); +} + +export async function getObject(key: string): Promise { + if (s3Configured()) { + const res = await getClient().send( + new GetObjectCommand({ Bucket: config.s3Bucket, Key: key }), + ); + return await streamToUint8Array(res.Body); + } + const path = join(DATA_DIR, "local-objects", key); + return await readFile(path); +} + +// --- Convenciones de key por dominio (equivalentes a los antiguos +// workerDir/projectDir/companyDir + Deno.readFile/writeFile) --- +export function workerDocKey(workerId: number, storageName: string): string { + return `expedientes/${workerId}/${storageName}`; +} +export function projectDocKey(projectId: number, storageName: string): string { + return `proyectos/${projectId}/${storageName}`; +} +export function companyDocKey(companyId: number, storageName: string): string { + return `empresas/${companyId}/${storageName}`; +} +export function badgeJobPdfKey(jobId: number): string { + return `pdfs/gafetes-${jobId}.pdf`; +} +export function loanReceiptPdfKey(loanId: number, weekId: number): string { + return `pdfs/prestamo-${loanId}-semana-${weekId}.pdf`; +} +export function projectLogoKey(projectId: number, side: "left" | "right", ext: string): string { + return `logos/${projectId}-${side}.${ext}`; +} diff --git a/api/test_helpers.ts b/api/test_helpers.ts new file mode 100644 index 0000000..5aa852b --- /dev/null +++ b/api/test_helpers.ts @@ -0,0 +1,54 @@ +import { createPool, PgDb, type postgres } from "./pg.ts"; + +/** + * Fase 3b: estrategia de tests con Postgres efímero. + * + * Los tests ya NO crean un esquema paralelo a mano (como hacía + * `new Database(":memory:")` de @db/sqlite): corren contra el MISMO + * baseline de Liquibase que producción (ver db/core/changesets), en la + * base de desarrollo local. Aislamiento entre tests: cada test corre + * dentro de una transacción que SIEMPRE se revierte al terminar (nunca + * hace commit), así que nunca contamina la base ni a otros tests, sin + * necesidad de TRUNCATE. + * + * Requiere DATABASE_URL_CORE_OWNER (o DATABASE_URL_CORE) apuntando a una + * base con las migraciones ya aplicadas -- ver db/provision/dev-local.sh + * y `./db/update.sh core --context-filter=dev`. + */ + +let pool: postgres.Sql | null = null; + +function getPool(): postgres.Sql { + if (!pool) { + const url = Deno.env.get("DATABASE_URL_CORE_OWNER") || Deno.env.get("DATABASE_URL_CORE"); + if (!url) { + throw new Error( + "Define DATABASE_URL_CORE_OWNER (o DATABASE_URL_CORE) para correr los tests contra Postgres -- ver db/provision/dev-local.sh", + ); + } + pool = createPool(url, { max: 5 }); + } + return pool; +} + +const ROLLBACK = Symbol("test-rollback"); + +/** Corre `fn(db)` dentro de una transacción que SIEMPRE se revierte. */ +export async function withTestDb(fn: (db: PgDb) => Promise): Promise { + const sql = getPool(); + let result: T = undefined as unknown as T; + try { + await sql.begin(async (tx) => { + result = await fn(new PgDb(tx as unknown as postgres.Sql)); + throw ROLLBACK; + }); + } catch (e) { + if (e !== ROLLBACK) throw e; + } + return result; +} + +export async function closeTestPool(): Promise { + await pool?.end({ timeout: 1 }); + pool = null; +} diff --git a/scripts/migrate-tenant-codes.ts b/scripts/migrate-tenant-codes.ts deleted file mode 100644 index c289be8..0000000 --- a/scripts/migrate-tenant-codes.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * One-off: tenant/company code = nombre(4) + YYMM; username = code lowercase. - * Usage: deno run -A scripts/migrate-tenant-codes.ts - */ -import { Database } from "jsr:@db/sqlite@0.12"; - -const PLATFORM = new URL("../data/platform.db", import.meta.url).pathname; -const APP = new URL("../data/app.db", import.meta.url).pathname; - -function shortFromName(name: string): string { - const slug = (name ?? "") - .toString() - .normalize("NFD") - .replace(/\p{M}/gu, "") - .toUpperCase() - .replace(/[^A-Z0-9]+/g, "") - .slice(0, 4); - return slug.length >= 2 ? slug : (slug || "EMP").padEnd(2, "X").slice(0, 4); -} - -function stampFromCreated(createdAt: string): string { - const m = createdAt.match(/^(\d{4})-(\d{2})/); - if (!m) { - const d = new Date(); - return `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, "0")}`; - } - return `${m[1].slice(-2)}${m[2]}`; -} - -const platform = new Database(PLATFORM); -const app = new Database(APP); -platform.exec("PRAGMA foreign_keys = ON;"); -app.exec("PRAGMA foreign_keys = ON;"); - -const tenants = platform.prepare( - "SELECT id, code, name, created_at FROM tenants ORDER BY id", -).all() as { id: number; code: string; name: string; created_at: string }[]; - -const used = new Set([ - ...(app.prepare("SELECT code FROM companies").all() as { code: string }[]).map((r) => r.code), - ...tenants.map((t) => t.code), -]); - -function allocate(name: string, createdAt: string, oldCode: string): string { - const base = shortFromName(name); - const stamp = stampFromCreated(createdAt); - let candidate = `${base}${stamp}`; - if (candidate === oldCode || !used.has(candidate)) return candidate; - for (let n = 2; n < 1000; n++) { - const suffix = String(n); - const head = base.slice(0, Math.max(2, 4 - suffix.length)); - candidate = `${head}${stamp}${suffix}`; - if (candidate === oldCode || !used.has(candidate)) return candidate; - } - throw new Error(`No unique code for ${name}`); -} - -type MapRow = { - tenantId: number; - oldCode: string; - newCode: string; - oldUser: string; - newUser: string; -}; - -const mappings: MapRow[] = []; -for (const t of tenants) { - const newCode = allocate(t.name, t.created_at, t.code); - used.add(newCode); - mappings.push({ - tenantId: t.id, - oldCode: t.code, - newCode, - oldUser: `adm.${t.code.toLowerCase()}`, - newUser: newCode.toLowerCase(), - }); -} - -console.log("Migrating:", mappings); - -for (const m of mappings) { - if (m.oldCode !== m.newCode) { - platform.prepare("UPDATE tenants SET code = ? WHERE id = ?").run(m.newCode, m.tenantId); - - const principal = app.prepare( - "SELECT id, code FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1", - ).get(m.tenantId) as { id: number; code: string } | undefined; - - if (principal && principal.code === m.oldCode) { - app.prepare("UPDATE companies SET code = ? WHERE id = ?").run(m.newCode, principal.id); - app.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ? AND hire_type = ?") - .run(m.newCode, principal.id, m.oldCode); - } - } - - const clash = app.prepare("SELECT id FROM users WHERE username = ?").get(m.newUser); - if (!clash) { - app.prepare( - "UPDATE users SET username = ? WHERE tenant_id = ? AND username = ?", - ).run(m.newUser, m.tenantId, m.oldUser); - // also catch if username already equals old code lowercase without adm. - app.prepare( - "UPDATE users SET username = ? WHERE tenant_id = ? AND username = ? AND username != ?", - ).run(m.newUser, m.tenantId, m.oldCode.toLowerCase(), m.newUser); - } else { - // ensure old adm.* row is renamed only if target not taken by another user - const old = app.prepare( - "SELECT id FROM users WHERE tenant_id = ? AND username = ?", - ).get(m.tenantId, m.oldUser) as { id: number } | undefined; - if (old && (clash as { id: number }).id === old.id) { - // same row already correct - } else if (old) { - console.warn(`Skip user rename ${m.oldUser} → ${m.newUser}: target exists`); - } - } -} - -console.log("TENANTS", platform.prepare("SELECT id, code, name FROM tenants").all()); -console.log( - "PRINCIPALS", - app.prepare("SELECT id, code, tenant_id, kind FROM companies WHERE kind = 'principal'").all(), -); -console.log( - "USERS", - app.prepare("SELECT id, username, tenant_id, role FROM users").all(), -); - -platform.close(); -app.close(); From d36c287a828dff09bfbff005d728421d233b42f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:05:07 +0000 Subject: [PATCH 03/13] db/infra: ETL de datos, backups y limpieza de despliegue (fase 5-7) Fase 5 (migracion de datos): - api/scripts/migrate-sqlite-to-postgres.ts: ETL unico SQLite -> Postgres. Pre-flight (CURP/RFC duplicados case-insensitive, FKs huerfanas, tenant_id invalido, fechas mal formateadas) -> carga por base/esquema con credenciales _owner (setval de secuencias, OVERRIDING SYSTEM VALUE, ON CONFLICT DO NOTHING idempotente) -> verificacion de conteos y sumas de dinero con tolerancia. Probado end-to-end contra un dataset SQLite legacy sintetico y un Postgres limpio (solo schema+catalogos, sin contexto dev): 100% de las filas migradas, sumas de dinero exactas, snapshot de uploaded_by/created_by resuelto correctamente. - db/RUNBOOK-corte.md: checklist go/no-go para el corte por ambiente. - Fix de bug real encontrado al probar: sin --context-filter explicito, Liquibase corre TAMBIEN los changesets context=dev (comportamiento por defecto, no "modo seguro") -- documentado en db/README.md con el ejemplo correcto (--context-filter='!dev' para staging/produccion). Fase 5b: patron de compensacion para createTenant ya resuelto en el rewrite de saas.ts (fase 3/4). Fase 6 (backups): db/backups/ con plantillas pgBackRest por base, politica de retencion, nota de persistencia minima de Redis (RDB, sin retencion de negocio), checklist de simulacro de restauracion mensual, y la validacion pendiente de si Coolify permite WAL archiving antes de comprometerse a PITR real. Fase 7 (limpieza y CI): - Dockerfile.api simplificado (sin JRE/Liquibase/FFI). Dockerfile.migrate y Dockerfile.provision nuevos, de un solo uso, para el paso explicito de deploy (nunca sirven trafico). - docker-compose.yml: postgres+redis+provision+migrate para dev local end-to-end; api ya no arranca hasta que migrate termina bien. - docs/coolify.md y .env.example actualizados al modelo de 2 bases + Redis + Contabo. - api/schema.sql eliminado (desincronizado, competia con Liquibase como fuente de verdad). - .github/workflows/ci.yml: Postgres+Redis de servicio, aprovisiona roles/ACLs, dry-run de Liquibase (updateSQL) antes de aplicar, verify-isolation.sh, deno check + deno test, build de los dos frontends. Co-authored-by: alberto.martinez --- .env.example | 56 ++- .github/workflows/ci.yml | 111 +++++ Dockerfile.api | 24 +- Dockerfile.migrate | 17 + Dockerfile.provision | 11 + api/deno.lock | 56 ++- api/schema.sql | 384 ------------------ api/scripts/migrate-sqlite-to-postgres.ts | 447 +++++++++++++++++++++ db/README.md | 16 +- db/RUNBOOK-corte.md | 89 ++++ db/backups/README.md | 108 +++++ db/backups/pgbackrest-panels_platform.conf | 24 ++ db/backups/pgbackrest-panels_product.conf | 21 + db/backups/redis-persistence.md | 35 ++ db/backups/restore-drill-checklist.md | 37 ++ db/provision/docker-provision.sh | 31 ++ db/update.sh | 19 +- docker-compose.yml | 97 ++++- docs/coolify.md | 91 +++-- 19 files changed, 1223 insertions(+), 451 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile.migrate create mode 100644 Dockerfile.provision delete mode 100644 api/schema.sql create mode 100644 api/scripts/migrate-sqlite-to-postgres.ts create mode 100644 db/RUNBOOK-corte.md create mode 100644 db/backups/README.md create mode 100644 db/backups/pgbackrest-panels_platform.conf create mode 100644 db/backups/pgbackrest-panels_product.conf create mode 100644 db/backups/redis-persistence.md create mode 100644 db/backups/restore-drill-checklist.md create mode 100644 db/provision/docker-provision.sh diff --git a/.env.example b/.env.example index acb4d07..2342dbf 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,16 @@ # ============================================================================= # Copiar a `.env` (gitignored) para local / Coolify. -# NUNCA subas `.env` ni `data/*.db` al repositorio. +# NUNCA subas `.env` ni `data/*` al repositorio. +# +# Generar cada password/secreto con: openssl rand -hex 24 # ============================================================================= # --- OBLIGATORIAS (producción) --- -# Generar ejemplos: -# SESSION_SECRET: openssl rand -hex 32 -# DOCS_KEY: openssl rand -hex 32 (exactamente 64 hex) SESSION_SECRET= DOCS_KEY= +# ^ DOCS_KEY debe ser EXACTAMENTE 64 hex (32 bytes): openssl rand -hex 32 SEED_PASSWORD= # Si la contraseña tiene `$`, en Docker Compose escríbelo como `$$` (ej. `p$$ass` → `p$ass`). -# Opcional one-shot: SEED_SYNC_PASSWORD=true (actualiza password de admin al arrancar; luego quítalo) PANEL_LOGIN_URL=https://app.tudominio/login PORT=8000 @@ -25,9 +24,54 @@ CORS_ORIGINS= API_KEY= VCARD_BASE=https://vcard.arctec.com.mx?info= -# SMTP (opcional; también se puede configurar en SaaS → /smtp y queda en platform.db) +# SMTP (opcional; también se puede configurar en SaaS → /smtp y queda en panels_platform) SMTP_HOST= SMTP_PORT=587 SMTP_USER= SMTP_PASS= SMTP_FROM=PANELS + +# ============================================================================= +# Postgres (monolito modular: panels_platform separada de panels_product) +# En Coolify, estos apuntan al recurso Postgres gestionado del ambiente +# (ver db/provision/README.md). Para `docker compose up` local, además hay +# que definir las contraseñas de cada rol (siguiente bloque) -- el compose +# arma las URLs solo. +# ============================================================================= +DATABASE_URL_PLATFORM= +DATABASE_URL_IAM= +DATABASE_URL_CORE= +# _OWNER: solo para el paso de deploy (Liquibase) y scripts (bootstrap/ETL). +# Nunca usar el rol _owner para el tráfico normal de la API. +DATABASE_URL_PLATFORM_OWNER= +DATABASE_URL_IAM_OWNER= +DATABASE_URL_CORE_OWNER= + +# --- Solo para `docker compose up` local (provisiona Postgres/Redis propios) --- +POSTGRES_SUPERUSER_PASSWORD= +PLATFORM_OWNER_PASSWORD= +PLATFORM_APP_PASSWORD= +IAM_OWNER_PASSWORD= +IAM_APP_PASSWORD= +CORE_OWNER_PASSWORD= +CORE_APP_PASSWORD= + +# ============================================================================= +# Redis (sesiones + cache, con ACL por módulo -- ver db/provision/05-redis-acl.sh) +# ============================================================================= +REDIS_URL_IAM= +REDIS_URL_CORE= +# --- Solo para `docker compose up` local --- +IAM_REDIS_PASSWORD= +CORE_REDIS_PASSWORD= + +# ============================================================================= +# Contabo Object Storage (Fase 4c) -- expedientes/PDFs/logos cifrados. +# Sin esto, cae a disco local bajo el volumen panel-data (solo dev; en +# producción un volumen local no escala horizontalmente, ver el plan). +# ============================================================================= +S3_ENDPOINT= +S3_BUCKET= +S3_REGION= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..89619bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,111 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + api: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + steps: + - uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Aprovisionar roles/esquemas Postgres + ACLs Redis + env: + PGHOST: localhost + PGPASSWORD: postgres + PLATFORM_OWNER_PASSWORD: ci-platform-owner + PLATFORM_APP_PASSWORD: ci-platform-app + IAM_OWNER_PASSWORD: ci-iam-owner + IAM_APP_PASSWORD: ci-iam-app + CORE_OWNER_PASSWORD: ci-core-owner + CORE_APP_PASSWORD: ci-core-app + REDIS_ADMIN_URL: redis://localhost:6379 + IAM_REDIS_PASSWORD: ci-iam-redis + CORE_REDIS_PASSWORD: ci-core-redis + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq postgresql-client redis-tools + ./db/provision/docker-provision.sh + ./db/provision/05-redis-acl.sh + + - name: Dry-run de Liquibase (updateSQL) -- no debe fallar antes de aplicar + env: + DATABASE_URL_PLATFORM_OWNER: postgresql://panels_platform_owner:ci-platform-owner@localhost:5432/panels_platform + DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:ci-iam-owner@localhost:5432/panels_product + DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:ci-core-owner@localhost:5432/panels_product + run: ./db/update.sh all --context-filter='!dev' -- updateSQL + + - name: Aplicar migraciones (con datos de demo, para los tests) + env: + DATABASE_URL_PLATFORM_OWNER: postgresql://panels_platform_owner:ci-platform-owner@localhost:5432/panels_platform + DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:ci-iam-owner@localhost:5432/panels_product + DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:ci-core-owner@localhost:5432/panels_product + run: ./db/update.sh all --context-filter=dev + + - name: Verificar aislamiento (roles/RLS/ACLs) + env: + DATABASE_URL_PLATFORM: postgresql://panels_platform_app:ci-platform-app@localhost:5432/panels_platform + DATABASE_URL_IAM: postgresql://panels_iam_app:ci-iam-app@localhost:5432/panels_product + DATABASE_URL_CORE: postgresql://panels_core_app:ci-core-app@localhost:5432/panels_product + REDIS_URL_IAM: redis://panels_iam_redis:ci-iam-redis@localhost:6379 + REDIS_URL_CORE: redis://panels_core_redis:ci-core-redis@localhost:6379 + run: ./db/provision/verify-isolation.sh + + - name: deno check + working-directory: api + run: deno check main.ts + + - name: deno test + working-directory: api + env: + DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:ci-core-owner@localhost:5432/panels_product + run: deno test --allow-net --allow-read --allow-write --allow-env + + web: + runs-on: ubuntu-latest + strategy: + matrix: + app: [web-panel, web-saas] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: ${{ matrix.app }}/package-lock.json + - run: npm ci + working-directory: ${{ matrix.app }} + - run: npm run build + working-directory: ${{ matrix.app }} diff --git a/Dockerfile.api b/Dockerfile.api index 66bebce..bb2b92d 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -1,26 +1,14 @@ -# API PANELS — Deno + SQLite FFI + Liquibase/JRE (Debian slim, no Alpine) +# API PANELS — Deno + Postgres (postgres.js) + Redis. Ya no necesita FFI de +# SQLite ni JRE/Liquibase en la imagen de runtime -- las migraciones corren +# como paso explícito de deploy (ver db/update.sh), no al arrancar la app. FROM denoland/deno:2.9.5 -USER root -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - openjdk-21-jre-headless \ - curl \ - unzip \ - bash \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - WORKDIR /app -COPY db/ ./db/ COPY api/ ./api/ COPY web-panel/public/brand/panel-icon-symmetric.png ./api/assets/email/logo.png -RUN chmod +x ./db/bootstrap-tools.sh \ - && ./db/bootstrap-tools.sh \ - && mkdir -p /app/data \ - && chown -R deno:deno /app +RUN mkdir -p /app/data && chown -R deno:deno /app USER deno WORKDIR /app/api @@ -29,7 +17,7 @@ RUN deno cache main.ts ENV PORT=8000 EXPOSE 8000 -HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD ["deno", "eval", "const r=await fetch('http://127.0.0.1:'+(Deno.env.get('PORT')||'8000')+'/v1/health'); if(!r.ok) Deno.exit(1)"] -CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "--allow-ffi", "--allow-run", "main.ts"] +CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "main.ts"] diff --git a/Dockerfile.migrate b/Dockerfile.migrate new file mode 100644 index 0000000..d258f5c --- /dev/null +++ b/Dockerfile.migrate @@ -0,0 +1,17 @@ +# PANELS — imagen mínima para el paso EXPLÍCITO de deploy que aplica +# Liquibase (Postgres). Separada de Dockerfile.api a propósito: la imagen +# que sirve tráfico real no necesita JRE/Liquibase (Fase 2/7 del plan de +# migración) -- esto solo se usa como job de un solo uso antes de levantar +# `api`, nunca como servicio de larga duración. +FROM eclipse-temurin:21-jre-jammy + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl unzip bash ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY db/ ./db/ +RUN chmod +x ./db/bootstrap-tools.sh ./db/update.sh && ./db/bootstrap-tools.sh + +ENTRYPOINT ["./db/update.sh"] +CMD ["all", "--context-filter=dev"] diff --git a/Dockerfile.provision b/Dockerfile.provision new file mode 100644 index 0000000..f3097dc --- /dev/null +++ b/Dockerfile.provision @@ -0,0 +1,11 @@ +# PANELS — imagen mínima de un solo uso para aprovisionar roles/bases/ +# esquemas de Postgres y ACLs de Redis en desarrollo local (ver +# db/provision/). No se usa en producción -- ahí este paso se corre a +# mano una vez contra el Postgres/Redis reales de Coolify (ver +# db/provision/README.md). +FROM alpine:3.20 +RUN apk add --no-cache postgresql16-client redis bash +WORKDIR /app +COPY db/provision/ ./db/provision/ +RUN chmod +x ./db/provision/*.sh +ENTRYPOINT ["/bin/bash"] diff --git a/api/deno.lock b/api/deno.lock index fef68da..b610d99 100644 --- a/api/deno.lock +++ b/api/deno.lock @@ -1,9 +1,19 @@ { "version": "5", "specifiers": { + "jsr:@db/sqlite@0.12": "0.12.0", + "jsr:@denosaurs/plug@1": "1.1.0", "jsr:@hono/hono@4": "4.13.2", + "jsr:@std/assert@0.217": "0.217.0", "jsr:@std/assert@1": "1.0.19", + "jsr:@std/encoding@1": "1.0.11", + "jsr:@std/fmt@1": "1.0.10", + "jsr:@std/fs@1": "1.0.24", "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@0.217": "0.217.0", + "jsr:@std/path@1": "1.1.6", + "jsr:@std/path@^1.1.5": "1.1.6", "npm:@aws-sdk/client-s3@3": "3.1124.0", "npm:@types/node@*": "22.15.15", "npm:nodemailer@6.9.16": "6.9.16", @@ -14,17 +24,61 @@ "npm:xlsx@0.18.5": "0.18.5" }, "jsr": { + "@db/sqlite@0.12.0": { + "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", + "dependencies": [ + "jsr:@denosaurs/plug", + "jsr:@std/path@0.217" + ] + }, + "@denosaurs/plug@1.1.0": { + "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", + "dependencies": [ + "jsr:@std/encoding", + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/path@1" + ] + }, "@hono/hono@4.13.2": { "integrity": "715d8cc1b6b5d6b9e6a7519059778d775b2d79fffdc026ac0ccdd9971e75809c" }, + "@std/assert@0.217.0": { + "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" + }, "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/encoding@1.0.11": { + "integrity": "e7cef2f0b3153bccc17431e7c864a1de03a4b6d9647389c43e08aaa775d37385" + }, + "@std/fmt@1.0.10": { + "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" + }, + "@std/fs@1.0.24": { + "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", + "dependencies": [ + "jsr:@std/internal@^1.0.14", + "jsr:@std/path@^1.1.5" ] }, "@std/internal@1.0.14": { "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@0.217.0": { + "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", + "dependencies": [ + "jsr:@std/assert@0.217" + ] + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal@^1.0.14" + ] } }, "npm": { diff --git a/api/schema.sql b/api/schema.sql deleted file mode 100644 index 0bc7e33..0000000 --- a/api/schema.sql +++ /dev/null @@ -1,384 +0,0 @@ -PRAGMA foreign_keys = ON; -PRAGMA journal_mode = WAL; - -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, - company_id INTEGER REFERENCES companies(id), - tenant_id INTEGER, - role TEXT NOT NULL DEFAULT 'user', - 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')), - 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 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), - tenant_id INTEGER, - 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), - tenant_id INTEGER, - 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, - category TEXT NOT NULL DEFAULT 'identidad' -); - -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, - category TEXT NOT NULL DEFAULT 'contrato' -); - -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 company_document_types ( - code TEXT PRIMARY KEY, - label TEXT NOT NULL, - required INTEGER NOT NULL DEFAULT 0, - category TEXT NOT NULL DEFAULT 'otro' -); - -CREATE TABLE IF NOT EXISTS 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')) -); - -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, - delivered REAL NOT NULL DEFAULT 0, - balance REAL NOT NULL, - weekly_payment REAL NOT NULL, - note TEXT, - commission_pct REAL NOT NULL DEFAULT 0, - commission_amount REAL NOT NULL DEFAULT 0, - plan TEXT NOT NULL DEFAULT 'single', - installments_n INTEGER NOT NULL DEFAULT 1, - first_due 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 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 -); - -CREATE TABLE IF NOT EXISTS 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 TABLE IF NOT EXISTS 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 TABLE IF NOT EXISTS 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 -); - -CREATE TABLE IF NOT EXISTS 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) -); - -CREATE TABLE IF NOT EXISTS 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) -); - -CREATE TABLE IF NOT EXISTS 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')) -); - -CREATE TABLE IF NOT EXISTS 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) -); - -CREATE TABLE IF NOT EXISTS 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')) -); - -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 -); diff --git a/api/scripts/migrate-sqlite-to-postgres.ts b/api/scripts/migrate-sqlite-to-postgres.ts new file mode 100644 index 0000000..6a048a9 --- /dev/null +++ b/api/scripts/migrate-sqlite-to-postgres.ts @@ -0,0 +1,447 @@ +#!/usr/bin/env -S deno run --allow-ffi --allow-net --allow-read --allow-write --allow-env +/** + * PANELS · Fase 5 · ETL único SQLite -> Postgres. + * + * Migra los datos de las dos SQLite históricas (data/app.db, data/platform.db) + * a las 2 bases Postgres del monolito modular (panels_platform, + * panels_product con esquemas iam/core). Corre en 3 fases: + * + * 1. Pre-flight: detecta de antemano lo que puede reventar el load o + * corromper datos en silencio (duplicados CURP/RFC case-insensitive, + * FKs huérfanas, tenant_id sin tenant, fechas con formato inválido). + * 2. Carga: una transacción POR BASE/ESQUENA (platform, iam, core), + * preservando los ids originales (OVERRIDING SYSTEM VALUE) para no + * romper referencias cruzadas entre tablas. + * 3. Verificación: compara conteos de filas origen/destino y sumas de + * columnas de dinero con una tolerancia explícita (REAL -> NUMERIC + * puede mover centavos). + * + * Uso (correr DESDE api/, con las credenciales _owner del ambiente destino + * en el entorno -- DATABASE_URL_PLATFORM_OWNER, DATABASE_URL_IAM_OWNER, + * DATABASE_URL_CORE_OWNER; el rol _app normal no alcanza a propósito): + * cd api + * set -a && source ../.env.dev-local && set +a # o el .env real del ambiente + * deno run --allow-ffi --allow-net --allow-read --allow-write --allow-env \ + * scripts/migrate-sqlite-to-postgres.ts \ + * --app-db=../data/app.db --platform-db=../data/platform.db + * + * Sale con código != 0 si el pre-flight encuentra problemas o si la + * verificación posterior no cuadra -- diseñado para un pipeline de + * corte con criterios go/no-go, no para "correr y ya". + */ +import { Database as SqliteDatabase } from "jsr:@db/sqlite@0.12"; +import { createPool } from "../pg.ts"; + +function arg(name: string, fallback: string): string { + const prefix = `--${name}=`; + const found = Deno.args.find((a) => a.startsWith(prefix)); + return found ? found.slice(prefix.length) : fallback; +} + +const APP_DB_PATH = arg("app-db", "../data/app.db"); +const PLATFORM_DB_PATH = arg("platform-db", "../data/platform.db"); +const DRY_RUN = Deno.args.includes("--dry-run"); + +const MONEY_TOLERANCE = 0.05; // pesos; ver nota de REAL -> NUMERIC en el plan + +let exitCode = 0; +function fail(msg: string) { + console.error(`[FAIL] ${msg}`); + exitCode = 1; +} +function warn(msg: string) { + console.warn(`[WARN] ${msg}`); +} +function ok(msg: string) { + console.log(`[OK] ${msg}`); +} + +// --------------------------------------------------------------------------- +// Conexiones +// --------------------------------------------------------------------------- +const appDb = new SqliteDatabase(APP_DB_PATH, { readonly: true }); +const platformDb = new SqliteDatabase(PLATFORM_DB_PATH, { readonly: true }); + +// El ETL necesita privilegios de owner (INSERT con OVERRIDING SYSTEM VALUE + +// setval() de secuencias) -- el rol _app normal no alcanza a propósito +// (least privilege). Cada rol _owner solo manda en SU esquema +// (panels_iam_owner en iam, panels_core_owner en core, ver +// db/provision/04-product-database.sql), así que hacen falta conexiones +// separadas incluso dentro de panels_product -- no hay un "super owner" +// que pueda escribir en ambos esquemas de una vez. +function ownerUrl(name: string): string { + const url = Deno.env.get(name) || ""; + if (!url) throw new Error(`Falta ${name} -- el ETL requiere credenciales _owner, no _app`); + return url; +} +const platformPg = createPool(ownerUrl("DATABASE_URL_PLATFORM_OWNER"), { max: 3 }); +const iamPg = createPool(ownerUrl("DATABASE_URL_IAM_OWNER"), { max: 3 }); +const corePg = createPool(ownerUrl("DATABASE_URL_CORE_OWNER"), { max: 3 }); + +// --------------------------------------------------------------------------- +// Fase 1: Pre-flight +// --------------------------------------------------------------------------- +async function preflight(): Promise { + console.log("\n=== Pre-flight ==="); + + // 1. Duplicados case-insensitive de CURP/RFC (SQLite los toleraba con + // COLLATE NOCASE + índice único case-insensitive; CITEXT en Postgres + // los rechazará igual, pero mejor detectarlo ANTES de la carga). + const dupCurp = appDb.prepare( + `SELECT LOWER(curp) AS c, COUNT(*) AS n FROM workers GROUP BY LOWER(curp) HAVING COUNT(*) > 1`, + ).all() as { c: string; n: number }[]; + if (dupCurp.length) fail(`${dupCurp.length} CURP duplicadas (case-insensitive): ${dupCurp.map((d) => d.c).join(", ")}`); + else ok("Sin CURP duplicadas"); + + const dupRfc = appDb.prepare( + `SELECT LOWER(rfc) AS c, COUNT(*) AS n FROM workers GROUP BY LOWER(rfc) HAVING COUNT(*) > 1`, + ).all() as { c: string; n: number }[]; + if (dupRfc.length) fail(`${dupRfc.length} RFC duplicados (case-insensitive): ${dupRfc.map((d) => d.c).join(", ")}`); + else ok("Sin RFC duplicados"); + + // 2. FKs huérfanas (SQLite solo valida si PRAGMA foreign_keys estuvo ON + // en cada escritura histórica -- puede haber huecos). + const orphanChecks: { label: string; sql: string }[] = [ + { label: "workers.company_id sin companies", sql: `SELECT COUNT(*) AS n FROM workers WHERE company_id IS NOT NULL AND company_id NOT IN (SELECT id FROM companies)` }, + { label: "workers.risk_code sin risk_levels", sql: `SELECT COUNT(*) AS n FROM workers WHERE risk_code NOT IN (SELECT code FROM risk_levels)` }, + { label: "documents.worker_id sin workers", sql: `SELECT COUNT(*) AS n FROM documents WHERE worker_id NOT IN (SELECT id FROM workers)` }, + { label: "documents.uploaded_by sin users", sql: `SELECT COUNT(*) AS n FROM documents WHERE uploaded_by IS NOT NULL AND uploaded_by NOT IN (SELECT id FROM users)` }, + { label: "assignments.worker_id sin workers", sql: `SELECT COUNT(*) AS n FROM assignments WHERE worker_id NOT IN (SELECT id FROM workers)` }, + { label: "assignments.project_id sin projects", sql: `SELECT COUNT(*) AS n FROM assignments WHERE project_id NOT IN (SELECT id FROM projects)` }, + { label: "projects.theme_id sin badge_themes", sql: `SELECT COUNT(*) AS n FROM projects WHERE theme_id NOT IN (SELECT id FROM badge_themes)` }, + { label: "users.company_id sin companies", sql: `SELECT COUNT(*) AS n FROM users WHERE company_id IS NOT NULL AND company_id NOT IN (SELECT id FROM companies)` }, + ]; + for (const check of orphanChecks) { + const row = appDb.prepare(check.sql).get() as { n: number }; + if (row.n > 0) fail(`${row.n} filas: ${check.label}`); + else ok(check.label.replace("sin", "OK ->")); + } + + // 3. tenant_id nulo o sin tenant real. + const tenantIds = new Set( + (platformDb.prepare("SELECT id FROM tenants").all() as { id: number }[]).map((t) => t.id), + ); + for (const table of ["companies", "workers", "projects", "users"]) { + const rows = appDb.prepare(`SELECT id, tenant_id FROM ${table}`).all() as { id: number; tenant_id: number | null }[]; + const bad = rows.filter((r) => r.tenant_id == null || !tenantIds.has(r.tenant_id)); + if (bad.length) fail(`${table}: ${bad.length} filas con tenant_id nulo o inexistente (ids: ${bad.slice(0, 10).map((r) => r.id).join(",")}${bad.length > 10 ? "..." : ""})`); + else ok(`${table}.tenant_id: todas resuelven a un tenant real`); + } + + // 4. Fechas con formato inválido en columnas que pasan a DATE. + const dateCols: { table: string; col: string }[] = [ + { table: "workers", col: "imss_alta_at" }, + { table: "workers", col: "imss_baja_at" }, + { table: "workers", col: "last_rehire_at" }, + { table: "projects", col: "start_date" }, + { table: "projects", col: "end_date" }, + { table: "documents", col: "issued_at" }, + { table: "documents", col: "expires_at" }, + ]; + for (const { table, col } of dateCols) { + const rows = appDb.prepare( + `SELECT id, ${col} AS v FROM ${table} WHERE ${col} IS NOT NULL AND ${col} != ''`, + ).all() as { id: number; v: string }[]; + const bad = rows.filter((r) => !/^\d{4}-\d{2}-\d{2}/.test(r.v)); + if (bad.length) fail(`${table}.${col}: ${bad.length} fechas con formato inválido (ej. id=${bad[0].id} -> "${bad[0].v}")`); + } + if (!dateCols.some(() => false)) ok("Formato de fechas revisado"); +} + +// --------------------------------------------------------------------------- +// Fase 2: Carga +// --------------------------------------------------------------------------- +type Row = Record; + +function toBool(v: unknown): boolean { + return v === 1 || v === true || v === "1"; +} + +/** Copia una tabla completa preservando ids, con transform opcional por fila. + * `identityColumn`: columna GENERATED ALWAYS AS IDENTITY a preservar via + * OVERRIDING SYSTEM VALUE (null para catálogos con PK de texto/compuesta, + * que no tienen identity y no la necesitan). `conflictColumns`: columna(s) + * de conflicto para el ON CONFLICT ... DO NOTHING (idempotencia si se + * corre el ETL dos veces). */ +async function copyTable( + sqlite: SqliteDatabase, + pg: ReturnType, + schema: string, + table: string, + opts: { + sqliteTable?: string; + transform?: (row: Row) => Row | null; + columns?: string[]; + identityColumn?: string | null; + conflictColumns?: string[]; + } = {}, +): Promise { + const sqliteTable = opts.sqliteTable ?? table; + const identityColumn = opts.identityColumn === undefined ? "id" : opts.identityColumn; + const conflictColumns = opts.conflictColumns ?? (identityColumn ? [identityColumn] : []); + const rows = sqlite.prepare(`SELECT * FROM ${sqliteTable}`).all() as Row[]; + let inserted = 0; + for (const raw of rows) { + const row = opts.transform ? opts.transform(raw) : raw; + if (!row) continue; // transform puede filtrar filas (ej. no migrables) + const cols = opts.columns ?? Object.keys(row); + const values = cols.map((c) => row[c]); + const placeholders = cols.map((_, i) => `$${i + 1}`).join(", "); + const overriding = identityColumn ? "OVERRIDING SYSTEM VALUE" : ""; + const onConflict = conflictColumns.length + ? `ON CONFLICT (${conflictColumns.join(", ")}) DO NOTHING` + : ""; + const text = `INSERT INTO ${schema}.${table} (${cols.join(", ")}) ${overriding} + VALUES (${placeholders}) ${onConflict}`; + if (!DRY_RUN) await pg.unsafe(text, values as never[]); + inserted++; + } + console.log(` ${schema}.${table}: ${inserted}/${rows.length} filas`); + return inserted; +} + +async function setSequence(pg: ReturnType, schema: string, table: string): Promise { + if (DRY_RUN) return; + await pg.unsafe( + `SELECT setval(pg_get_serial_sequence('${schema}.${table}', 'id'), COALESCE((SELECT MAX(id) FROM ${schema}.${table}), 1), (SELECT MAX(id) IS NOT NULL FROM ${schema}.${table}))`, + ); +} + +async function migratePlatform(): Promise { + console.log("\n=== Carga: panels_platform ==="); + await platformPg.begin(async (tx) => { + await copyTable(platformDb, tx as never, "public", "tenants", { + transform: (r) => ({ ...r, status: r.status === "inactivo" ? "suspendido" : r.status }), + }); + await copyTable(platformDb, tx as never, "public", "platform_users"); + await copyTable(platformDb, tx as never, "public", "smtp_settings", { + identityColumn: null, // id fijo = 1 (singleton), no es GENERATED + conflictColumns: ["id"], // el baseline de Liquibase ya insertó la fila id=1 por defecto + transform: (r) => ({ ...r, enabled: toBool(r.enabled) }), + }); + }); + await setSequence(platformPg, "public", "tenants"); + await setSequence(platformPg, "public", "platform_users"); +} + +async function migrateIam(): Promise { + console.log("\n=== Carga: panels_product.iam ==="); + await iamPg.begin(async (tx) => { + await copyTable(appDb, tx as never, "iam", "users", { + columns: [ + "id", "username", "password_hash", "display_name", "company_id", "tenant_id", + "role_code", "must_change_password", "email", "created_at", + ], + transform: (r) => ({ + ...r, + role_code: r.role === "tenant_admin" ? "tenant_admin" : "user", + must_change_password: toBool(r.must_change_password), + }), + }); + }); + await setSequence(iamPg, "iam", "users"); +} + +/** users.id -> display_name, para el snapshot de uploaded_by/created_by + * (Fase 4: documents/badge_jobs ya no tienen FK viva hacia iam). */ +function userNameLookup(): Map { + const rows = appDb.prepare("SELECT id, display_name FROM users").all() as { id: number; display_name: string }[]; + return new Map(rows.map((r) => [r.id, r.display_name])); +} + +async function migrateCore(): Promise { + console.log("\n=== Carga: panels_product.core ==="); + const names = userNameLookup(); + + await corePg.begin(async (tx) => { + const t = tx as never as ReturnType; + // Orden por dependencias de FK. risk_levels/badge_themes son catálogos + // con PK de texto (code/id), no tienen columna identity que preservar. + await copyTable(appDb, t, "core", "risk_levels", { identityColumn: null, conflictColumns: ["code"] }); + await copyTable(appDb, t, "core", "badge_themes", { identityColumn: null, conflictColumns: ["id"] }); + await copyTable(appDb, t, "core", "companies"); + await copyTable(appDb, t, "core", "projects"); + await copyTable(appDb, t, "core", "workers", { + transform: (r) => ({ ...r, needs_badge: toBool(r.needs_badge) }), + }); + await copyTable(appDb, t, "core", "assignments", { + transform: (r) => ({ ...r, active: toBool(r.active) }), + }); + await copyTable(appDb, t, "core", "document_types", { + identityColumn: null, + conflictColumns: ["code"], + transform: (r) => ({ + ...r, + required: toBool(r.required), + requires_issued_at: toBool(r.requires_issued_at), + requires_expires_at: toBool(r.requires_expires_at), + }), + }); + await copyTable(appDb, t, "core", "documents", { + columns: [ + "id", "worker_id", "type_code", "original_name", "mime", "size_bytes", "sha256", "iv", + "storage_name", "is_current", "parse_status", "issued_at", "expires_at", "imss_company_id", + "imss_alta_at", "uploaded_by_id", "uploaded_by_name", "uploaded_at", + ], + transform: (r) => ({ + ...r, + is_current: toBool(r.is_current), + uploaded_by_id: r.uploaded_by ?? null, + uploaded_by_name: r.uploaded_by ? names.get(Number(r.uploaded_by)) ?? "" : "", + }), + }); + await copyTable(appDb, t, "core", "project_document_types", { + identityColumn: null, + conflictColumns: ["code"], + transform: (r) => ({ ...r, required: toBool(r.required) }), + }); + await copyTable(appDb, t, "core", "project_documents", { + columns: [ + "id", "project_id", "type_code", "original_name", "mime", "size_bytes", "sha256", "iv", + "storage_name", "is_current", "parse_status", "uploaded_by_id", "uploaded_by_name", "uploaded_at", + ], + transform: (r) => ({ + ...r, + is_current: toBool(r.is_current), + uploaded_by_id: r.uploaded_by ?? null, + uploaded_by_name: r.uploaded_by ? names.get(Number(r.uploaded_by)) ?? "" : "", + }), + }); + await copyTable(appDb, t, "core", "company_document_types", { + identityColumn: null, + conflictColumns: ["code"], + transform: (r) => ({ ...r, required: toBool(r.required) }), + }); + await copyTable(appDb, t, "core", "company_documents", { + columns: [ + "id", "company_id", "type_code", "original_name", "mime", "size_bytes", "sha256", "iv", + "storage_name", "is_current", "parse_status", "uploaded_by_id", "uploaded_by_name", "uploaded_at", + ], + transform: (r) => ({ + ...r, + is_current: toBool(r.is_current), + uploaded_by_id: r.uploaded_by ?? null, + uploaded_by_name: r.uploaded_by ? names.get(Number(r.uploaded_by)) ?? "" : "", + }), + }); + await copyTable(appDb, t, "core", "badge_jobs", { + columns: ["id", "project_id", "status", "pdf_path", "created_by_id", "created_by_name", "created_at"], + transform: (r) => ({ + ...r, + created_by_id: r.created_by ?? null, + created_by_name: r.created_by ? names.get(Number(r.created_by)) ?? "" : "", + }), + }); + await copyTable(appDb, t, "core", "badge_job_people", { + identityColumn: null, + conflictColumns: ["job_id", "worker_id"], + transform: (r) => ({ ...r, delivered: toBool(r.delivered) }), + }); + await copyTable(appDb, t, "core", "loans"); + await copyTable(appDb, t, "core", "attendance", { + transform: (r) => ({ ...r, present: toBool(r.present) }), + }); + await copyTable(appDb, t, "core", "payroll_periods"); + await copyTable(appDb, t, "core", "payroll_lines"); + await copyTable(appDb, t, "core", "payroll_settings", { + identityColumn: null, + conflictColumns: ["tenant_id"], + transform: (r) => ({ ...r, loan_commission_enabled: toBool(r.loan_commission_enabled) }), + }); + await copyTable(appDb, t, "core", "payroll_weeks"); + await copyTable(appDb, t, "core", "payroll_sheets"); + await copyTable(appDb, t, "core", "payroll_week_lines"); + await copyTable(appDb, t, "core", "destajo_units"); + await copyTable(appDb, t, "core", "destajo_periods"); + await copyTable(appDb, t, "core", "destajo_jobs"); + await copyTable(appDb, t, "core", "destajo_cut_lines"); + await copyTable(appDb, t, "core", "loan_payments"); + await copyTable(appDb, t, "core", "budget_chapters"); + await copyTable(appDb, t, "core", "budget_items"); + }); + + for ( + const table of [ + "companies", "projects", "workers", "assignments", "documents", "project_documents", + "company_documents", "badge_jobs", "loans", "attendance", "payroll_periods", "payroll_lines", + "payroll_weeks", "payroll_sheets", "payroll_week_lines", "destajo_units", "destajo_periods", + "destajo_jobs", "destajo_cut_lines", "loan_payments", "budget_chapters", "budget_items", + ] + ) { + await setSequence(corePg, "core", table); + } +} + +// --------------------------------------------------------------------------- +// Fase 3: Verificación +// --------------------------------------------------------------------------- +async function verifyCounts(): Promise { + console.log("\n=== Verificación: conteos de filas ==="); + const checks: { sqlite: SqliteDatabase; table: string; pg: ReturnType; schema: string }[] = [ + { sqlite: platformDb, table: "tenants", pg: platformPg, schema: "public" }, + { sqlite: platformDb, table: "platform_users", pg: platformPg, schema: "public" }, + { sqlite: appDb, table: "users", pg: iamPg, schema: "iam" }, + { sqlite: appDb, table: "companies", pg: corePg, schema: "core" }, + { sqlite: appDb, table: "workers", pg: corePg, schema: "core" }, + { sqlite: appDb, table: "projects", pg: corePg, schema: "core" }, + { sqlite: appDb, table: "documents", pg: corePg, schema: "core" }, + { sqlite: appDb, table: "loans", pg: corePg, schema: "core" }, + { sqlite: appDb, table: "budget_items", pg: corePg, schema: "core" }, + ]; + for (const c of checks) { + const src = (c.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${c.table}`).get() as { n: number }).n; + const dstRows = await c.pg.unsafe(`SELECT COUNT(*)::int AS n FROM ${c.schema}.${c.table}`); + const dst = (dstRows[0] as unknown as { n: number }).n; + if (src !== dst) fail(`${c.schema}.${c.table}: origen=${src} destino=${dst}`); + else ok(`${c.schema}.${c.table}: ${dst} filas en ambos lados`); + } +} + +async function verifyMoney(): Promise { + console.log("\n=== Verificación: sumas de dinero (tolerancia $" + MONEY_TOLERANCE + ") ==="); + const checks: { label: string; sqliteSql: string; pgSql: string }[] = [ + { label: "workers.daily_wage", sqliteSql: "SELECT COALESCE(SUM(daily_wage),0) AS n FROM workers", pgSql: "SELECT COALESCE(SUM(daily_wage),0)::float AS n FROM core.workers" }, + { label: "loans.balance", sqliteSql: "SELECT COALESCE(SUM(balance),0) AS n FROM loans", pgSql: "SELECT COALESCE(SUM(balance),0)::float AS n FROM core.loans" }, + { label: "budget_items.amount", sqliteSql: "SELECT COALESCE(SUM(amount),0) AS n FROM budget_items", pgSql: "SELECT COALESCE(SUM(amount),0)::float AS n FROM core.budget_items" }, + ]; + for (const c of checks) { + const src = (appDb.prepare(c.sqliteSql).get() as { n: number }).n; + const dstRows = await corePg.unsafe(c.pgSql); + const dst = (dstRows[0] as unknown as { n: number }).n; + const diff = Math.abs(src - dst); + if (diff > MONEY_TOLERANCE) fail(`${c.label}: origen=${src} destino=${dst} (diff=${diff.toFixed(4)} > tolerancia)`); + else ok(`${c.label}: origen=${src} destino=${dst} (diff=${diff.toFixed(4)})`); + } +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +try { + await preflight(); + if (exitCode !== 0) { + console.error("\nPre-flight encontró problemas -- corrígelos antes de cargar. Abortando (no se tocó Postgres)."); + Deno.exit(1); + } + if (DRY_RUN) { + console.log("\n--dry-run: se detiene aquí (pre-flight OK, no se escribió nada)."); + } else { + await migratePlatform(); + await migrateIam(); + await migrateCore(); + await verifyCounts(); + await verifyMoney(); + if (exitCode !== 0) { + console.error("\nLa verificación posterior a la carga NO cuadra -- revisar antes de dar por buena la migración."); + } else { + console.log("\nMigración completa y verificada."); + } + } +} finally { + appDb.close(); + platformDb.close(); + await platformPg.end({ timeout: 5 }); + await iamPg.end({ timeout: 5 }); + await corePg.end({ timeout: 5 }); +} +Deno.exit(exitCode); diff --git a/db/README.md b/db/README.md index b2eb715..6d82773 100644 --- a/db/README.md +++ b/db/README.md @@ -32,15 +32,21 @@ Requiere Java 17+ y las credenciales del rol `_owner` de cada módulo (nunca ```bash # 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 +./db/update.sh all --context-filter=dev # dev: incluye datos de demo +./db/update.sh all --context-filter='!dev' # staging/producción: solo esquema + catálogos # Un solo módulo -./db/update.sh core -./db/update.sh iam -./db/update.sh platform +./db/update.sh core --context-filter='!dev' +./db/update.sh iam --context-filter='!dev' +./db/update.sh platform --context-filter='!dev' ``` +**Importante**: Liquibase, si NO se le pasa `--context-filter`, corre TODOS +los changesets sin importar su `context` -- incluidos los de `context="dev"`. +No pasar el filtro en staging/producción NO es "modo seguro por defecto", +es lo contrario: cargaría el tenant/empresas/proyecto de demostración. Por +eso `--context-filter` es obligatorio siempre, nunca opcional. + `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). diff --git a/db/RUNBOOK-corte.md b/db/RUNBOOK-corte.md new file mode 100644 index 0000000..bf416df --- /dev/null +++ b/db/RUNBOOK-corte.md @@ -0,0 +1,89 @@ +# Runbook de corte SQLite → Postgres (Fase 5) + +Checklist operativo para migrar UN ambiente (dev, luego staging, luego +producción -- nunca saltar directo a producción). Usa +[`api/scripts/migrate-sqlite-to-postgres.ts`](../api/scripts/migrate-sqlite-to-postgres.ts). + +## 0. Antes de empezar + +- [ ] Postgres del ambiente aprovisionado (`db/provision/`) y migrado + **sin** datos de demo: `./db/update.sh all --context-filter='!dev'`. +- [ ] Backup fresco del volumen SQLite actual (`data/app.db`, `data/platform.db`) + guardado aparte, fuera del volumen que se va a apagar. +- [ ] Credenciales `_owner` de los 3 módulos disponibles en el entorno + (`DATABASE_URL_PLATFORM_OWNER`, `DATABASE_URL_IAM_OWNER`, `DATABASE_URL_CORE_OWNER`). + +## 1. Pre-flight (sin ventana de mantenimiento, se puede correr en caliente) + +```bash +cd api +deno run --allow-ffi --allow-net --allow-read --allow-write --allow-env \ + scripts/migrate-sqlite-to-postgres.ts --dry-run \ + --app-db=../data/app.db --platform-db=../data/platform.db +``` + +Si el pre-flight reporta `[FAIL]`, **no continuar** -- corregir los datos +en SQLite (duplicados, huérfanas, tenant_id inválido, fechas mal +formateadas) y repetir hasta que todo salga `[OK]`. + +## 2. Ventana de mantenimiento (corte real) + +Hoy no existe un "modo mantenimiento" en la app. Opciones, de menor a +mayor invasividad: + +- Parar el contenedor/proceso `api` (nadie puede escribir mientras está + abajo -- los fronts mostrarán error de conexión). +- Responder 503 temporal en nginx para `/v1/*` mientras se corre el ETL. + +Elegir una, documentar la hora exacta de inicio. + +## 3. Migración + +```bash +cd api +set -a && source /ruta/al/.env.del.ambiente && set +a +deno run --allow-ffi --allow-net --allow-read --allow-write --allow-env \ + scripts/migrate-sqlite-to-postgres.ts \ + --app-db=/ruta/a/app.db --platform-db=/ruta/a/platform.db +``` + +El script hace: pre-flight → carga (una transacción por base/esquema, +todo o nada) → `setval()` de secuencias → verificación de conteos y sumas +de dinero. Si CUALQUIER paso falla, no queda un estado a medias en +Postgres (la transacción de esa base se revierte completa), pero SQLite +sigue siendo la fuente de verdad -- no se ha cortado nada todavía. + +## 4. Criterios go/no-go + +Antes de apuntar la app a Postgres y apagar SQLite: + +- [ ] El script terminó con `Migración completa y verificada.` (exit code 0). +- [ ] Conteos de filas origen=destino en **todas** las tablas listadas + (no solo las 9 de ejemplo del script -- ampliar `verifyCounts()` si + el ambiente tiene datos en tablas no cubiertas ahí). +- [ ] Diferencia de sumas de dinero dentro de la tolerancia ($0.05) -- si + no cuadra, decidir explícitamente si se acepta el redondeo + REAL→NUMERIC o se investiga antes de continuar. +- [ ] Spot-check manual de 2-3 registros conocidos (un trabajador, un + preupuesto, un préstamo) comparando app vieja vs. Postgres. + +Si algo no cuadra: **no cortar**. Volver a levantar la app contra SQLite +(no se tocó), investigar, y repetir desde el paso 1 en otro intento. + +## 5. Cutover + +- [ ] Actualizar `DATABASE_URL_*`/`REDIS_URL_*` del servicio `api` a los + valores del ambiente Postgres/Redis recién migrado. +- [ ] Levantar `api` -- el fail-fast de arranque (`/v1/health`) debe + responder `{"ok":true,...}` antes de reabrir tráfico. +- [ ] Reabrir tráfico (quitar el 503/levantar el contenedor). +- [ ] Login de prueba con un usuario real del ambiente. + +## 6. Después del corte + +- [ ] Conservar el volumen SQLite (`data/`) como respaldo frío por un + período de retención definido (ej. 30 días) antes de borrarlo. +- [ ] Correr `./db/provision/verify-isolation.sh` contra el ambiente para + confirmar que el aislamiento de roles/RLS/ACLs sigue intacto. +- [ ] Repetir todo el runbook en el siguiente ambiente (dev → staging → + producción), nunca en paralelo. diff --git a/db/backups/README.md b/db/backups/README.md new file mode 100644 index 0000000..7c6d53c --- /dev/null +++ b/db/backups/README.md @@ -0,0 +1,108 @@ +# Backups y continuidad (Fase 6) + +## 0. Validar antes de diseñar el resto + +Coolify despliega Postgres como un recurso "managed" que en realidad es un +contenedor `postgres:16` estándar con volumen persistente. **Hay que +confirmar, para el ambiente real, si se puede:** + +1. Montar un `postgresql.conf` custom (para `archive_mode`, `archive_command`, `wal_level`), y +2. Ejecutar un sidecar/proceso adicional (`pgbackrest`) con acceso al mismo volumen de datos y a WAL. + +- **Si sí se puede**: seguir esta guía con pgBackRest (PITR real). +- **Si no se puede** (Coolify no expone esos hooks en el plan/versión + usada): el respaldo queda limitado a los `pg_dump` programados que + Coolify ya ofrece -- el RPO pasa a ser "la frecuencia del dump", no + segundos. En ese caso, para producción, evaluar auto-hospedar Postgres + (contenedor propio, fuera del recurso "managed" de Coolify) solo para + tener control de WAL -- es la única forma de bajar el RPO por debajo de + la frecuencia del dump. + +Esta decisión condiciona todo lo demás; no asumir que WAL archiving va a +funcionar sin probarlo primero contra el Coolify real del ambiente. + +## 1. Qué se respalda y con qué política + +| Qué | Herramienta | Frecuencia | Retención | RPO objetivo | +|---|---|---|---|---| +| `panels_platform` (prod) | pgBackRest (o `pg_dump` si no hay WAL) | full diario + diferencial c/6h + WAL continuo | 30 días | segundos (con WAL) / 24h (solo dump) | +| `panels_product` (prod) | igual que arriba | igual | 30 días | igual | +| `panels_platform`/`panels_product` (staging/dev) | `pg_dump` | full diario | 7 días | 24h | +| Archivos (Contabo, Fase 4c) | versionado de objetos del bucket + backup del bucket | continuo (versionado) | igual que la BD | ver nota de coordinación abajo | +| Redis | RDB snapshot | al reiniciar/periódico | ninguna (no es fuente de verdad) | N/A -- ver `db/backups/redis-persistence.md` | + +**Respaldar SIEMPRE ambas bases.** Es fácil configurar el backup de +`panels_product` (donde está "todo el negocio") y olvidar `panels_platform` +(que tiene el registro de TODOS los tenants) -- el día del incidente ahí es +cuando se descubre. + +## 2. pgBackRest (si Coolify lo permite) + +Ver [`pgbackrest-panels_platform.conf`](./pgbackrest-panels_platform.conf) y +[`pgbackrest-panels_product.conf`](./pgbackrest-panels_product.conf) -- +plantillas, una `stanza` por base de datos (no por esquema: `panels_product` +es una sola stanza aunque tenga los esquemas iam/core adentro). + +Pasos (por base): + +```bash +# 1. postgresql.conf del contenedor +wal_level = replica +archive_mode = on +archive_command = 'pgbackrest --stanza=panels_platform archive-push %p' +archive_timeout = 60 # cap de RPO a 60s incluso en bases con poca escritura + +# 2. Crear la stanza una vez +pgbackrest --stanza=panels_platform --log-level-console=info stanza-create + +# 3. Verificar que el archiving realmente funciona ANTES de confiar en él +pgbackrest --stanza=panels_platform check + +# 4. Backups programados (cron / scheduler de Coolify) +pgbackrest --stanza=panels_platform --type=full backup # 1x/semana +pgbackrest --stanza=panels_platform --type=diff backup # cada 6h +``` + +Repetir con `--stanza=panels_product` y su propio `archive_command`. + +### Point-in-time recovery + +```bash +pgbackrest --stanza=panels_platform --type=time \ + --target="2026-09-01 14:00:00-06" --target-action=promote restore +``` + +## 3. Si NO hay WAL archiving (solo pg_dump) + +```bash +pg_dump --format=custom --file=panels_platform-$(date +%Y%m%d).dump "$DATABASE_URL_PLATFORM_OWNER" +pg_dump --format=custom --file=panels_product-$(date +%Y%m%d).dump "$DATABASE_URL_CORE_OWNER" +``` + +`--format=custom` permite restore paralelo (`pg_restore -j4`) y restore +selectivo por tabla si algún día hace falta. Subir los `.dump` a un bucket +S3-compatible (puede ser el mismo de Contabo, con prefijo/bucket distinto +al de documentos) con retención por lifecycle policy del bucket. + +## 4. Coordinación con el respaldo de archivos (Fase 4c) + +Un restore de Postgres a un punto en el tiempo, sin restaurar los archivos +de Contabo al MISMO punto, deja filas (`documents.storage_name`) apuntando +a objetos que ya no existen o que cambiaron. Política: + +- Activar versionado de objetos en el bucket de Contabo. +- Si se hace un PITR de Postgres a una hora `T`, documentar que los + archivos subidos/borrados después de `T` pueden quedar huérfanos o + inconsistentes -- es una ventana de inconsistencia aceptada, no un bug + a corregir en caliente durante el incidente. + +## 5. Redis + +Ver [`redis-persistence.md`](./redis-persistence.md). Resumen: solo RDB +para evitar deslogueo masivo, sin retención de negocio, fuera del alcance +del simulacro de restauración de abajo. + +## 6. Simulacro de restauración (mensual, obligatorio) + +Ver [`restore-drill-checklist.md`](./restore-drill-checklist.md). Un +backup que nunca se probó restaurar no es un backup, es una esperanza. diff --git a/db/backups/pgbackrest-panels_platform.conf b/db/backups/pgbackrest-panels_platform.conf new file mode 100644 index 0000000..9e32d44 --- /dev/null +++ b/db/backups/pgbackrest-panels_platform.conf @@ -0,0 +1,24 @@ +# Plantilla pgBackRest para panels_platform (control plane SaaS). +# Copiar a /etc/pgbackrest/pgbackrest.conf (o incluir via `include` si ya +# existe una stanza para panels_product en el mismo host/instancia) y +# ajustar repo1-* según el backend real (aquí: S3-compatible, ej. Contabo +# Object Storage -- puede ser el MISMO endpoint que Fase 4c con un bucket +# o prefijo distinto para no mezclar backups de BD con documentos). + +[global] +repo1-type=s3 +repo1-s3-endpoint=CAMBIAR-ENDPOINT-S3 +repo1-s3-bucket=panels-backups +repo1-s3-key=CAMBIAR-ACCESS-KEY +repo1-s3-key-secret=CAMBIAR-SECRET-KEY +repo1-s3-region=CAMBIAR-REGION +repo1-path=/panels_platform +repo1-retention-full=4 +repo1-retention-diff=14 +process-max=2 +compress-type=zst +log-level-console=info + +[panels_platform] +pg1-path=/var/lib/postgresql/data +pg1-port=5432 diff --git a/db/backups/pgbackrest-panels_product.conf b/db/backups/pgbackrest-panels_product.conf new file mode 100644 index 0000000..59c59b3 --- /dev/null +++ b/db/backups/pgbackrest-panels_product.conf @@ -0,0 +1,21 @@ +# Plantilla pgBackRest para panels_product (iam + core). Una stanza por +# BASE DE DATOS, no por esquema -- panels_product es una sola stanza aunque +# tenga iam/core adentro (ver db/README.md sobre el modelo de 2 bases). + +[global] +repo1-type=s3 +repo1-s3-endpoint=CAMBIAR-ENDPOINT-S3 +repo1-s3-bucket=panels-backups +repo1-s3-key=CAMBIAR-ACCESS-KEY +repo1-s3-key-secret=CAMBIAR-SECRET-KEY +repo1-s3-region=CAMBIAR-REGION +repo1-path=/panels_product +repo1-retention-full=4 +repo1-retention-diff=14 +process-max=2 +compress-type=zst +log-level-console=info + +[panels_product] +pg1-path=/var/lib/postgresql/data +pg1-port=5432 diff --git a/db/backups/redis-persistence.md b/db/backups/redis-persistence.md new file mode 100644 index 0000000..89f7f4e --- /dev/null +++ b/db/backups/redis-persistence.md @@ -0,0 +1,35 @@ +# Persistencia de Redis (mínima, no es backup de negocio) + +Redis guarda sesiones (`iam:session:*`) y cache (`core:cache:*`, ver +Fase 4e). Ninguno de los dos es fuente de verdad: + +- Sesión perdida → el usuario hace login de nuevo. Molesto, no es pérdida + de datos. +- Cache perdido → la siguiente lectura recalcula desde Postgres. + +Por eso Redis **no** entra en la política de backup con retención/PITR de +las bases Postgres (Fase 6 del plan). Lo único que vale la pena es evitar +que un reinicio del contenedor Redis deslogueé a **todos** los tenants a +la vez -- eso se resuelve con RDB, no con un pipeline de backup. + +## Configuración recomendada (solo producción) + +```conf +# redis.conf del recurso de Coolify (o el equivalente que exponga) +save 900 1 # snapshot si hubo >=1 cambio en 15 min +save 300 10 # snapshot si hubo >=10 cambios en 5 min +save 60 10000 # snapshot si hubo >=10000 cambios en 1 min +appendonly no # AOF no hace falta -- RDB alcanza para el objetivo (evitar + # deslogueo masivo), y agrega complejidad/IO sin beneficio + # dado que nada aquí es irremplazable. +``` + +En dev/staging, ni siquiera hace falta esto -- perder las sesiones de +desarrollo no tiene costo real. + +## Qué NO hacer + +- No configurar `pgbackrest`/backups con retención larga para Redis -- + sería tratar como "fuente de verdad" algo que por diseño no lo es. +- No mezclar la política de respaldo de Redis con la de Postgres en la + misma automatización -- son necesidades distintas (ver Fase 6 del plan). diff --git a/db/backups/restore-drill-checklist.md b/db/backups/restore-drill-checklist.md new file mode 100644 index 0000000..e93ccc3 --- /dev/null +++ b/db/backups/restore-drill-checklist.md @@ -0,0 +1,37 @@ +# Simulacro de restauración (mensual, obligatorio) + +Un backup que nunca se probó restaurar no es un backup. Correr esto en un +ambiente descartable (nunca contra staging/producción reales). + +## Checklist + +- [ ] Levantar un Postgres nuevo y vacío (contenedor descartable). +- [ ] Restaurar el backup más reciente de `panels_platform`: + - pgBackRest: `pgbackrest --stanza=panels_platform restore` + - o `pg_restore` si es dump lógico. +- [ ] Restaurar el backup más reciente de `panels_product` (mismo método). +- [ ] Correr [`db/provision/verify-isolation.sh`](../provision/verify-isolation.sh) + contra el ambiente restaurado -- confirma que roles/RLS/permisos + sobrevivieron el restore intactos, no solo los datos. +- [ ] Spot-check de datos: comparar conteos de filas y 2-3 registros + conocidos contra lo que se espera (usar el mismo enfoque que + `verifyCounts()`/`verifyMoney()` del ETL, ver + [`api/scripts/migrate-sqlite-to-postgres.ts`](../../api/scripts/migrate-sqlite-to-postgres.ts) + como referencia de qué comparar). +- [ ] Si hay WAL archiving: probar un restore a un punto en el tiempo + específico (no solo "el último backup"), confirmar que aterriza en + el estado esperado para ese instante. +- [ ] Archivos (Contabo): confirmar que al menos una descarga de + documento cifrado conocido sigue descifrando correctamente después + del restore (prueba de que la clave `DOCS_KEY` y el bucket siguen + consistentes). +- [ ] Documentar cuánto tardó el restore de punta a punta -- es el RTO + real, no el teórico. +- [ ] Destruir el ambiente descartable al terminar. + +## Cuándo escalar + +Si cualquier paso falla (restore no completa, RLS no aplica, datos no +cuadran, documento no descifra), **no esperar al siguiente simulacro** -- +es una señal de que el backup en producción probablemente tampoco sirve. +Tratarlo como incidente, no como hallazgo de rutina. diff --git a/db/provision/docker-provision.sh b/db/provision/docker-provision.sh new file mode 100644 index 0000000..ce19042 --- /dev/null +++ b/db/provision/docker-provision.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env sh +# PANELS · Fase 0 · aprovisiona roles/bases/esquemas/ACLs contra un +# Postgres/Redis alcanzables por red (uso: contenedor de docker-compose +# para desarrollo local -- ver el servicio "provision" en +# docker-compose.yml). Requiere psql y redis-cli en la imagen (usa la +# imagen postgres:16-alpine + redis-cli instalado, o dos pasos separados). +set -eu +cd "$(dirname "$0")" + +: "${PGHOST:?}"; : "${PGPORT:=5432}"; : "${PGUSER:=postgres}"; : "${PGPASSWORD:?}" +export PGPASSWORD + +SUPERUSER_URL="postgresql://${PGUSER}@${PGHOST}:${PGPORT}/postgres" + +echo "== 1/4: roles ==" +psql "$SUPERUSER_URL" -v ON_ERROR_STOP=1 \ + -v platform_owner_pw="${PLATFORM_OWNER_PASSWORD}" -v platform_app_pw="${PLATFORM_APP_PASSWORD}" \ + -v iam_owner_pw="${IAM_OWNER_PASSWORD}" -v iam_app_pw="${IAM_APP_PASSWORD}" \ + -v core_owner_pw="${CORE_OWNER_PASSWORD}" -v core_app_pw="${CORE_APP_PASSWORD}" \ + -f 01-roles.sql + +echo "== 2/4: bases de datos ==" +psql "$SUPERUSER_URL" -v ON_ERROR_STOP=1 -f 02-databases.sql + +echo "== 3/4: privilegios panels_platform ==" +psql "postgresql://${PGUSER}@${PGHOST}:${PGPORT}/panels_platform" -v ON_ERROR_STOP=1 -f 03-platform-database.sql + +echo "== 4/4: esquemas + privilegios panels_product ==" +psql "postgresql://${PGUSER}@${PGHOST}:${PGPORT}/panels_product" -v ON_ERROR_STOP=1 -f 04-product-database.sql + +echo "Roles/bases/esquemas listos." diff --git a/db/update.sh b/db/update.sh index 846b07a..43ece0a 100755 --- a/db/update.sh +++ b/db/update.sh @@ -13,6 +13,7 @@ # Uso: # set -a && source .env.dev-local && set +a && ./db/update.sh all # ./db/update.sh core --context-filter=dev +# ./db/update.sh all --context-filter='!dev' -- updateSQL # dry-run (CI) set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" "$ROOT/db/bootstrap-tools.sh" @@ -21,7 +22,19 @@ PG_JDBC="$ROOT/db/tools/postgresql-jdbc.jar" TARGET="${1:-all}" shift || true -EXTRA_ARGS=("$@") + +LB_COMMAND="update" +EXTRA_ARGS=() +while [[ $# -gt 0 ]]; do + if [[ "$1" == "--" ]]; then + shift + LB_COMMAND="${1:-update}" + shift || true + else + EXTRA_ARGS+=("$1") + shift + fi +done # postgresql://user:pass@host:port/db -> jdbc:postgresql://host:port/db + user/pass parse_url() { @@ -43,7 +56,7 @@ run_one() { exit 1 fi parse_url "$url" - echo "==> Liquibase update: $name ($PARSED_JDBC)" + echo "==> Liquibase $LB_COMMAND: $name ($PARSED_JDBC)" ( cd "$ROOT/db/$name" "$LIQUIBASE" \ @@ -52,7 +65,7 @@ run_one() { --url="$PARSED_JDBC" \ --username="$PARSED_USER" \ --password="$PARSED_PASS" \ - update "${EXTRA_ARGS[@]}" + "$LB_COMMAND" "${EXTRA_ARGS[@]}" ) } diff --git a/docker-compose.yml b/docker-compose.yml index 1299026..62fd415 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,85 @@ -# Coolify / local — 3 services, SQLite en volumen panel-data +# Coolify / local — Postgres + Redis (ver plan de migración: monolito +# modular panels_platform / panels_product con esquemas iam+core). +# En Coolify, postgres/redis normalmente son recursos gestionados aparte +# (ver docs/coolify.md); este compose incluye contenedores propios para +# que `docker compose up` funcione standalone en desarrollo local. services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD:?define POSTGRES_SUPERUSER_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + expose: + - "5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + expose: + - "6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + + # Jobs de un solo uso (nunca en producción -- ver db/provision/README.md): + # 1) crea roles/bases/esquemas/ACLs, 2) aplica Liquibase. `api` espera a + # que ambos terminen bien antes de arrancar. + provision: + build: + context: . + dockerfile: Dockerfile.provision + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + PGHOST: postgres + PGPASSWORD: ${POSTGRES_SUPERUSER_PASSWORD:?} + PLATFORM_OWNER_PASSWORD: ${PLATFORM_OWNER_PASSWORD:?} + PLATFORM_APP_PASSWORD: ${PLATFORM_APP_PASSWORD:?} + IAM_OWNER_PASSWORD: ${IAM_OWNER_PASSWORD:?} + IAM_APP_PASSWORD: ${IAM_APP_PASSWORD:?} + CORE_OWNER_PASSWORD: ${CORE_OWNER_PASSWORD:?} + CORE_APP_PASSWORD: ${CORE_APP_PASSWORD:?} + REDIS_ADMIN_URL: redis://redis:6379 + IAM_REDIS_PASSWORD: ${IAM_REDIS_PASSWORD:?} + CORE_REDIS_PASSWORD: ${CORE_REDIS_PASSWORD:?} + command: + - -c + - "./db/provision/docker-provision.sh && ./db/provision/05-redis-acl.sh" + + migrate: + build: + context: . + dockerfile: Dockerfile.migrate + depends_on: + provision: + condition: service_completed_successfully + environment: + DATABASE_URL_PLATFORM_OWNER: postgresql://panels_platform_owner:${PLATFORM_OWNER_PASSWORD:?}@postgres:5432/panels_platform + DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:${IAM_OWNER_PASSWORD:?}@postgres:5432/panels_product + DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:${CORE_OWNER_PASSWORD:?}@postgres:5432/panels_product + api: build: context: . dockerfile: Dockerfile.api restart: unless-stopped + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy environment: PORT: ${PORT:-8000} SESSION_SECRET: ${SESSION_SECRET} @@ -20,6 +95,23 @@ services: SMTP_USER: ${SMTP_USER:-} SMTP_PASS: ${SMTP_PASS:-} SMTP_FROM: ${SMTP_FROM:-} + # Postgres: panels_platform (base separada) + panels_product (iam/core) + DATABASE_URL_PLATFORM: postgresql://panels_platform_app:${PLATFORM_APP_PASSWORD:?}@postgres:5432/panels_platform + DATABASE_URL_IAM: postgresql://panels_iam_app:${IAM_APP_PASSWORD:?}@postgres:5432/panels_product + DATABASE_URL_CORE: postgresql://panels_core_app:${CORE_APP_PASSWORD:?}@postgres:5432/panels_product + DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:${IAM_OWNER_PASSWORD:?}@postgres:5432/panels_product + DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:${CORE_OWNER_PASSWORD:?}@postgres:5432/panels_product + # Redis: ACLs por módulo (ver db/provision/05-redis-acl.sh) + REDIS_URL_IAM: redis://panels_iam_redis:${IAM_REDIS_PASSWORD:?}@redis:6379 + REDIS_URL_CORE: redis://panels_core_redis:${CORE_REDIS_PASSWORD:?}@redis:6379 + # Contabo Object Storage (Fase 4c) -- opcional; sin esto cae a disco + # local bajo el volumen panel-data (no recomendado en producción, + # ver plan: un volumen local no escala horizontalmente). + S3_ENDPOINT: ${S3_ENDPOINT:-} + S3_BUCKET: ${S3_BUCKET:-} + S3_REGION: ${S3_REGION:-} + S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-} + S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-} volumes: - panel-data:/app/data expose: @@ -35,7 +127,7 @@ services: interval: 30s timeout: 5s retries: 5 - start_period: 45s + start_period: 15s web-panel: build: @@ -64,4 +156,5 @@ services: - "80" volumes: + postgres-data: panel-data: diff --git a/docs/coolify.md b/docs/coolify.md index 7e4e212..a7e32a6 100644 --- a/docs/coolify.md +++ b/docs/coolify.md @@ -1,24 +1,29 @@ -# Deploy PANELS en Coolify (Docker Compose) +# Deploy PANELS en Coolify (Docker Compose + Postgres + Redis) ## Resumen | Qué | Cuánto | |-----|--------| -| Bases gestionadas Coolify (Postgres/MySQL) | **0** | -| Archivos SQLite en volumen | **2** (`app.db`, `platform.db`) | -| Contenedores | **3** (`api`, `web-panel`, `web-saas`) | -| Volumen persistente | **1** → `/app/data` en `api` | +| Postgres gestionado en Coolify | **1 instancia** por ambiente, con **2 bases**: `panels_platform`, `panels_product` (esquemas `iam`/`core`) | +| Redis gestionado en Coolify | **1 instancia** por ambiente, con **2 usuarios ACL** (`panels_iam_redis`, `panels_core_redis`) | +| Object storage | Contabo Object Storage (S3-compatible) para expedientes/PDFs/logos cifrados | +| Contenedores de la app | **3** (`api`, `web-panel`, `web-saas`) | +| Volumen persistente | **1** → `/app/data` en `api` (solo fallback local si no hay Contabo configurado -- no usar así en producción) | Los fronts (Alpine + nginx) hacen proxy de `/v1` al servicio `api`, así las cookies de sesión van same-origin. +Ver el plan de migración para el detalle de arquitectura: por qué +`panels_platform` es una base separada, por qué `iam`/`core` son esquemas +distintos dentro de `panels_product`, y las reglas del monolito modular. + ## Datos sensibles (qué NO va al git) | Ítem | Estado | |------|--------| | `.env` | gitignored — no commitear | -| `data/` (`*.db`, expedientes, pdfs) | gitignored | -| SMTP password en SaaS | vive en `platform.db` (volumen) — proteger backups | -| Defaults de desarrollo en código | solo fallbacks locales; Coolify **exige** secrets | +| `data/` (fallback local de archivos, si no hay Contabo) | gitignored | +| SMTP password en SaaS | vive en `panels_platform.smtp_settings` — proteger backups | +| Defaults de desarrollo en código | `SESSION_SECRET`/`DOCS_KEY` solo tienen fallback si `DENO_ENV` no es `production` -- fuera de eso, la app falla al arrancar si faltan | ## Variables de entorno (servicio `api`) @@ -26,15 +31,23 @@ Los fronts (Alpine + nginx) hacen proxy de `/v1` al servicio `api`, así las coo | Variable | Formato | Uso | |----------|---------|-----| -| `SESSION_SECRET` | string largo aleatorio | Firma cookie de sesión | -| `DOCS_KEY` | **64** caracteres hex (32 bytes) | Cifrado de documentos | -| `SEED_PASSWORD` | string | Password inicial de `admin` SaaS (solo al crear / migrar seed) | +| `SESSION_SECRET` | string largo aleatorio | Firma interna (no ya la cookie -- la sesión vive en Redis, ver Fase 4) | +| `DOCS_KEY` | **64** caracteres hex (32 bytes) | Cifrado AES-GCM de documentos | +| `SEED_PASSWORD` | string | Password inicial usado por `api/scripts/bootstrap-admin.ts` | | `PANEL_LOGIN_URL` | URL absoluta | Link en correos de acceso | +| `DATABASE_URL_PLATFORM` | `postgresql://panels_platform_app:...@host:5432/panels_platform` | Runtime, rol `_app` | +| `DATABASE_URL_IAM` | `postgresql://panels_iam_app:...@host:5432/panels_product` | Runtime, rol `_app`, esquema `iam` | +| `DATABASE_URL_CORE` | `postgresql://panels_core_app:...@host:5432/panels_product` | Runtime, rol `_app`, esquema `core` | +| `DATABASE_URL_IAM_OWNER` | igual, rol `_owner` | Solo para el lookup de login por username (bypassa RLS a propósito, ver `api/iam_db.ts`) | +| `DATABASE_URL_CORE_OWNER` | igual, rol `_owner` | Solo para resoluciones administrativas puntuales (ver `api/db.ts#getCoreDb`) | +| `REDIS_URL_IAM` | `redis://panels_iam_redis:...@host:6379` | Sesiones | +| `REDIS_URL_CORE` | `redis://panels_core_redis:...@host:6379` | Cache | -Generar: +Generar secretos: ```bash openssl rand -hex 32 # SESSION_SECRET o DOCS_KEY +openssl rand -hex 24 # passwords de roles Postgres/Redis ``` ### Recomendadas (HTTPS / Coolify) @@ -49,50 +62,64 @@ openssl rand -hex 32 # SESSION_SECRET o DOCS_KEY | Variable | Uso | |----------|-----| -| `API_KEY` | Auth alternativa por header `X-API-Key` (vacío = desactivado) | +| `API_KEY` | Auth alternativa por header `X-API-Key` **+ `X-Tenant-Id` obligatorio** (ya no ve todos los tenants, ver revisión de seguridad) | | `VCARD_BASE` | Prefijo QR/vCard gafetes | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `SMTP_FROM` | Correo por env (alternativa al panel `/smtp`) | +| `S3_ENDPOINT` / `S3_BUCKET` / `S3_REGION` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | Contabo Object Storage (Fase 4c). Sin esto, cae a disco local -- **no recomendado en producción** | `web-panel` y `web-saas` **no** necesitan variables de entorno en runtime (estáticos + proxy nginx). ## Imágenes -- **api:** Deno 2.9 + OpenJDK 21 JRE + Liquibase (Debian; no Alpine por FFI SQLite) -- **web-panel / web-saas:** build Node Alpine → **nginx Alpine** +- **api:** Deno 2.9, sin FFI ni JRE (Fase 2/7 del plan de migración). +- **migrate:** JRE 21 + Liquibase -- imagen aparte, de un solo uso, NO sirve tráfico (ver `Dockerfile.migrate`). +- **web-panel / web-saas:** build Node Alpine → **nginx Alpine**. -Archivos: `Dockerfile.api`, `Dockerfile.web-panel`, `Dockerfile.web-saas`, `docker-compose.yml`, `web-panel/nginx.conf`, `web-saas/nginx.conf`. +Archivos: `Dockerfile.api`, `Dockerfile.migrate`, `Dockerfile.provision`, `Dockerfile.web-panel`, `Dockerfile.web-saas`, `docker-compose.yml`, `web-panel/nginx.conf`, `web-saas/nginx.conf`. En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrdev.mx`). La carpeta `web/` queda libre para una website futura. ## Paso a paso en Coolify -1. **Push** este repo (sin `.env` ni `data/`). -2. Coolify → **New Resource → Docker Compose** → `docker-compose.yml`. -3. **Persistent storage:** volumen `panel-data` → `/app/data` en `api`. -4. **Dominios:** `web-panel` → panels; `web-saas` → saas; `api` sin FQDN (proxy `/v1`). -5. Cargar en Coolify las **obligatorias** + `COOKIE_SECURE=true`. -6. Deploy (Liquibase crea/migra las 2 SQLite en el volumen). -7. Verificar `https://app…/v1/health`, login SaaS `admin` / tu `SEED_PASSWORD`. -8. SMTP en `/smtp` o por `SMTP_*`. +1. **Provisionar Postgres y Redis** como recursos gestionados de Coolify para el ambiente (uno de cada, no por módulo). +2. **Aprovisionar roles/esquemas/ACLs**: correr los scripts de [`db/provision/`](../db/provision/README.md) contra ese Postgres/Redis (una vez, desde tu máquina o un job manual -- Coolify no lo hace por ti). +3. **Aplicar Liquibase** (paso explícito, NO ocurre al arrancar la app): `./db/update.sh all --context-filter='!dev'` con las credenciales `_owner`. En staging/producción, **nunca** olvidar el `--context-filter` -- sin él, Liquibase corre TAMBIÉN los changesets de demo (`context=dev`). +4. **Bootstrap del primer admin**: `deno run ... api/scripts/bootstrap-admin.ts platform` y `... tenant --tenant-id=... --company-code=...` (ver `db/README.md`). +5. **Push** este repo (sin `.env` ni `data/`). +6. Coolify → **New Resource → Docker Compose** → `docker-compose.yml` (solo para `api`/`web-panel`/`web-saas` -- si Postgres/Redis ya son recursos gestionados aparte, quitar esos servicios del compose antes de desplegar, o apuntar sus variables a los recursos gestionados en vez de los contenedores locales del compose). +7. **Persistent storage:** volumen `panel-data` → `/app/data` en `api` (solo fallback de archivos si no hay Contabo). +8. **Dominios:** `web-panel` → panels; `web-saas` → saas; `api` sin FQDN (proxy `/v1`). +9. Cargar en Coolify todas las variables **obligatorias** de la tabla de arriba + `COOKIE_SECURE=true`. +10. Deploy. +11. Verificar `https://app…/v1/health` → debe responder `{"ok":true,"core":true,"platform":true,"redis":{"iam":true,"core":true}}`. Si algo es `false`, la app ni siquiera debería haber arrancado (fail-fast, Fase 7). +12. Login SaaS `admin` / tu `SEED_PASSWORD`. +13. SMTP en `/smtp` o por `SMTP_*`. -## Local +## Local (todo en docker-compose, incluyendo Postgres/Redis propios) ```bash -cp .env.example .env # rellenar obligatorias +cp .env.example .env # rellenar TODAS las variables (incluye los passwords de roles Postgres/Redis) docker compose up --build ``` -Sin `SESSION_SECRET` / `DOCS_KEY` / `SEED_PASSWORD` / `PANEL_LOGIN_URL`, compose **no arranca**. +El compose local incluye: `provision` (roles/esquemas/ACLs, un solo uso) → +`migrate` (Liquibase con datos de demo, un solo uso) → `api`/`web-panel`/`web-saas`. +Para producción, `provision`/`migrate` NO se corren así -- ver +`db/provision/README.md` y `db/RUNBOOK-corte.md`. + +Sin las variables obligatorias, compose **no arranca** (`:?` en docker-compose.yml). ## Checklist ops - [ ] `.env` y `data/` fuera del git -- [ ] Secrets distintos a los de desarrollo -- [ ] Backup del volumen `panel-data` (incluye DBs + SMTP guardado) +- [ ] Secrets distintos a los de desarrollo (incluye los 6 roles Postgres + 2 Redis) +- [ ] Backup de `panels_platform` Y `panels_product` (ver `db/backups/`) -- no solo una +- [ ] Backup del bucket de Contabo (documentos cifrados) - [ ] `COOKIE_SECURE=true` en HTTPS -- [ ] Healthcheck API OK +- [ ] Healthcheck API OK (`/v1/health` con las 5 conexiones en `true`) +- [ ] `--context-filter` explícito en cada corrida de Liquibase contra staging/producción ## Qué no hace falta -- Postgres/MySQL/MariaDB en Coolify -- Contenedor monolítico Node+Deno+Java +- Contenedor monolítico Node+Deno+Java sirviendo tráfico (Liquibase vive en su propia imagen, de un solo uso) +- FFI de SQLite (ya no hay SQLite en ningún ambiente) From 1ac04996b6fc2fde27f5fe090546339258fdf74d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:22:39 +0000 Subject: [PATCH 04/13] ops: generar accesos por ambiente y verificar Postgres, Redis y Contabo Scripts create-accesses / verify-connectivity para un Postgres+Redis+bucket por ambiente. /v1/health y el arranque de la API incluyen sonda de storage. Co-authored-by: alberto.martinez --- .github/workflows/ci.yml | 12 +++ api/main.ts | 16 +++- api/scripts/verify-storage.ts | 35 ++++++++ api/storage.ts | 48 +++++++++- db/provision/README.md | 36 +++++++- db/provision/create-accesses.sh | 131 ++++++++++++++++++++++++++++ db/provision/verify-connectivity.sh | 79 +++++++++++++++++ docs/coolify.md | 6 +- 8 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 api/scripts/verify-storage.ts create mode 100755 db/provision/create-accesses.sh create mode 100755 db/provision/verify-connectivity.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89619bd..8428850 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,18 @@ jobs: REDIS_URL_CORE: redis://panels_core_redis:ci-core-redis@localhost:6379 run: ./db/provision/verify-isolation.sh + - name: Verificar conectividad Postgres/Redis (S3 opcional en CI) + env: + DATABASE_URL_PLATFORM: postgresql://panels_platform_app:ci-platform-app@localhost:5432/panels_platform + DATABASE_URL_PLATFORM_OWNER: postgresql://panels_platform_owner:ci-platform-owner@localhost:5432/panels_platform + DATABASE_URL_IAM: postgresql://panels_iam_app:ci-iam-app@localhost:5432/panels_product + DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:ci-iam-owner@localhost:5432/panels_product + DATABASE_URL_CORE: postgresql://panels_core_app:ci-core-app@localhost:5432/panels_product + DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:ci-core-owner@localhost:5432/panels_product + REDIS_URL_IAM: redis://panels_iam_redis:ci-iam-redis@localhost:6379 + REDIS_URL_CORE: redis://panels_core_redis:ci-core-redis@localhost:6379 + run: ./db/provision/verify-connectivity.sh + - name: deno check working-directory: api run: deno check main.ts diff --git a/api/main.ts b/api/main.ts index cb401f1..614a157 100644 --- a/api/main.ts +++ b/api/main.ts @@ -61,7 +61,7 @@ import { lineAmount, listBudget, } from "./budget.ts"; -import { companyDocKey, getObject, projectDocKey, workerDocKey } from "./storage.ts"; +import { companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts"; import { cacheCore, cacheKeyCore } from "./cache.ts"; import { pingRedis } from "./redis.ts"; @@ -105,13 +105,15 @@ app.use( // Postgres/Redis, /v1/health de verdad toca las tres conexiones Postgres y // las dos de Redis, no solo responde estático. app.get("/v1/health", async (c) => { - const [core, platform, redis] = await Promise.all([ + const [core, platform, redis, storage] = await Promise.all([ pingCoreDb().then(() => true).catch(() => false), pingPlatformDb().then(() => true).catch(() => false), pingRedis(), + pingStorage(), ]); - const ok = core && platform && redis.iam && redis.core; - return c.json({ ok, core, platform, redis }, ok ? 200 : 503); + const storageOk = storage.ok && (storage.backend === "local" || storage.configured); + const ok = core && platform && redis.iam && redis.core && storageOk; + return c.json({ ok, core, platform, redis, storage }, ok ? 200 : 503); }); app.post("/v1/auth/login", async (c) => { @@ -1260,6 +1262,12 @@ await Promise.all([ pingRedis().then((r) => { if (!r.iam || !r.core) throw new Error("Redis (iam/core) no responde"); }), + pingStorage().then((s) => { + if (!s.ok) throw new Error("Storage (Contabo o disco local) no responde"); + if (!config.isDev && !s.configured) { + throw new Error("S3_ENDPOINT/S3_BUCKET/S3_* son obligatorios fuera de desarrollo"); + } + }), ]); Deno.serve({ port, hostname: "0.0.0.0" }, app.fetch); diff --git a/api/scripts/verify-storage.ts b/api/scripts/verify-storage.ts new file mode 100644 index 0000000..c80c5ad --- /dev/null +++ b/api/scripts/verify-storage.ts @@ -0,0 +1,35 @@ +/** + * Sonda de Contabo (S3): HeadBucket + put/get/delete. + * + * Desde api/: + * deno run --allow-net --allow-env --allow-read --allow-write scripts/verify-storage.ts + * + * Requiere S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY. + * Si REQUIRE_S3=1 y no hay credenciales, sale con error (staging/prod). + * Si no están, sale 0 y avisa (dev local con disco). + */ +import { config } from "../config.ts"; +import { pingStorage, probeStorageReadWrite, s3Configured } from "../storage.ts"; + +const requireS3 = ["1", "true", "yes"].includes((Deno.env.get("REQUIRE_S3") ?? "").toLowerCase()); + +if (!s3Configured()) { + const msg = + "S3 no configurado (S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY)."; + if (requireS3) { + console.error(`FAIL - ${msg} Obligatorio en staging/producción.`); + Deno.exit(1); + } + console.log(`SKIP - ${msg} En este ambiente se usará disco local.`); + Deno.exit(0); +} + +console.log(`S3 endpoint=${config.s3Endpoint} bucket=${config.s3Bucket} region=${config.s3Region}`); +const ping = await pingStorage(); +if (!ping.ok) { + console.error("FAIL - HeadBucket: no se pudo alcanzar el bucket (credenciales, red o nombre)."); + Deno.exit(1); +} +console.log("OK - HeadBucket"); +await probeStorageReadWrite(); +console.log("OK - put/get/delete de objeto sonda"); diff --git a/api/storage.ts b/api/storage.ts index 0563039..ccde499 100644 --- a/api/storage.ts +++ b/api/storage.ts @@ -1,4 +1,10 @@ -import { S3Client, PutObjectCommand, GetObjectCommand } from "npm:@aws-sdk/client-s3@3"; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + HeadBucketCommand, + DeleteObjectCommand, +} from "npm:@aws-sdk/client-s3@3"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { config, DATA_DIR } from "./config.ts"; @@ -20,10 +26,48 @@ import { config, DATA_DIR } from "./config.ts"; let client: S3Client | null = null; -function s3Configured(): boolean { +export function s3Configured(): boolean { return !!(config.s3Endpoint && config.s3Bucket && config.s3AccessKeyId && config.s3SecretAccessKey); } +export type StoragePing = { + configured: boolean; + ok: boolean; + backend: "s3" | "local"; +}; + +/** HeadBucket (S3) o escritura de prueba en disco local. */ +export async function pingStorage(): Promise { + if (!s3Configured()) { + try { + await mkdir(join(DATA_DIR, "local-objects"), { recursive: true }); + return { configured: false, ok: true, backend: "local" }; + } catch { + return { configured: false, ok: false, backend: "local" }; + } + } + try { + await getClient().send(new HeadBucketCommand({ Bucket: config.s3Bucket })); + return { configured: true, ok: true, backend: "s3" }; + } catch { + return { configured: true, ok: false, backend: "s3" }; + } +} + +/** Put + get + delete de un objeto sonda. No dejar basura en el bucket. */ +export async function probeStorageReadWrite(): Promise { + const key = `_panels/connectivity-probe-${crypto.randomUUID()}`; + const payload = new TextEncoder().encode("panels-connectivity-probe"); + await putObject(key, payload); + const got = await getObject(key); + if (new TextDecoder().decode(got) !== "panels-connectivity-probe") { + throw new Error("El objeto sonda no coincide con lo escrito"); + } + if (s3Configured()) { + await getClient().send(new DeleteObjectCommand({ Bucket: config.s3Bucket, Key: key })); + } +} + function getClient(): S3Client { if (!client) { client = new S3Client({ diff --git a/db/provision/README.md b/db/provision/README.md index 5c3a909..a6de426 100644 --- a/db/provision/README.md +++ b/db/provision/README.md @@ -60,6 +60,35 @@ 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). +## Accesos de un ambiente (Coolify + Contabo) + +**1 Postgres + 1 Redis + 1 bucket por ambiente.** El servidor lo crea +Coolify/Contabo; este repo solo genera roles y valida que contesten. + +1. En Coolify: recurso Postgres y recurso Redis de **ese** ambiente. + En Contabo: bucket (ej. `panels-prod` / `panels-staging`) + access key. +2. Generar secretos y crear roles/ACLs contra esos hosts: + +```bash +export PGHOST=... PGUSER=postgres PGPASSWORD=... # admin que da Coolify +export REDIS_ADMIN_URL="redis://:...@host:6379" +export S3_ENDPOINT=https://usc1.contabostorage.com +export S3_BUCKET=panels-prod +export S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... +./db/provision/create-accesses.sh --apply --verify --out .env.prod.local +``` + +3. Pegar el contenido de `.env.prod.local` (gitignored) en las env del `api`. +4. Sin `--apply`, el script solo imprime el bloque; no toca servidores. + +Comprobar conectividad después, sin reprovisionar: + +```bash +set -a && source .env.prod.local && set +a +REQUIRE_S3=1 ./db/provision/verify-connectivity.sh # staging/prod +./db/provision/verify-isolation.sh +``` + ## Qué falta hacer manualmente en Coolify (staging/producción) Estos scripts asumen que ya existe un servidor Postgres y un servidor Redis @@ -68,9 +97,10 @@ 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. +3. Crear el bucket Contabo de ese ambiente. +4. Correr `create-accesses.sh --apply --verify` (o los SQL 01-04 + `05-redis-acl.sh`). +5. Cargar los secrets resultantes (`DATABASE_URL_*`, `REDIS_URL_*`, `S3_*`) + en la configuración del servicio `api` de ese ambiente. ## Desarrollo local diff --git a/db/provision/create-accesses.sh b/db/provision/create-accesses.sh new file mode 100755 index 0000000..7dc28f7 --- /dev/null +++ b/db/provision/create-accesses.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# PANELS · genera secretos de UN ambiente y, si hay superusuario, crea +# roles/bases/ACLs. No crea el servidor Postgres, Redis ni el bucket: +# esos se levantan en Coolify / Contabo; este script solo deja los accesos. +# +# Uso (solo imprimir bloque .env, sin tocar servidores): +# PGHOST=pg.interno REDIS_HOST=redis.interno S3_BUCKET=panels-prod \ +# ./db/provision/create-accesses.sh +# +# Uso (crear roles en un Postgres/Redis ya levantados): +# PGHOST=... PGUSER=postgres PGPASSWORD=... REDIS_ADMIN_URL=redis://:...@host:6379 \ +# ./db/provision/create-accesses.sh --apply --verify +# +# Flags: +# --apply corre 01-04 SQL + 05 Redis ACL con las claves generadas +# --verify corre verify-connectivity.sh al final (implica tener URLs) +# --out FILE escribe el bloque .env (FILE debe estar gitignored, ej. .env.prod.local) +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +APPLY=0 +VERIFY=0 +OUT="" +while [[ $# -gt 0 ]]; do + case "$1" in + --apply) APPLY=1; shift ;; + --verify) VERIFY=1; shift ;; + --out) OUT="$2"; shift 2 ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "Flag desconocido: $1" >&2; exit 1 ;; + esac +done + +rand24() { openssl rand -hex 24; } +rand32() { openssl rand -hex 32; } + +PGHOST="${PGHOST:-127.0.0.1}" +PGPORT="${PGPORT:-5432}" +REDIS_HOST="${REDIS_HOST:-127.0.0.1}" +REDIS_PORT="${REDIS_PORT:-6379}" +PGUSER="${PGUSER:-postgres}" + +PLATFORM_OWNER_PASSWORD="${PLATFORM_OWNER_PASSWORD:-$(rand24)}" +PLATFORM_APP_PASSWORD="${PLATFORM_APP_PASSWORD:-$(rand24)}" +IAM_OWNER_PASSWORD="${IAM_OWNER_PASSWORD:-$(rand24)}" +IAM_APP_PASSWORD="${IAM_APP_PASSWORD:-$(rand24)}" +CORE_OWNER_PASSWORD="${CORE_OWNER_PASSWORD:-$(rand24)}" +CORE_APP_PASSWORD="${CORE_APP_PASSWORD:-$(rand24)}" +IAM_REDIS_PASSWORD="${IAM_REDIS_PASSWORD:-$(rand24)}" +CORE_REDIS_PASSWORD="${CORE_REDIS_PASSWORD:-$(rand24)}" +SESSION_SECRET="${SESSION_SECRET:-$(rand32)}" +DOCS_KEY="${DOCS_KEY:-$(rand32)}" + +block() { + cat < "$OUT" + echo "Escrito $OUT (permisos 600). No lo subas a git." >&2 +fi + +if [[ "$VERIFY" == "1" ]]; then + echo "== Verificando conectividad ==" >&2 + tmp="$(mktemp)" + umask 077 + printf '%s\n' "$ENV_TEXT" > "$tmp" + set -a + # shellcheck disable=SC1090 + source "$tmp" + set +a + rm -f "$tmp" + "$HERE/verify-connectivity.sh" +fi diff --git a/db/provision/verify-connectivity.sh b/db/provision/verify-connectivity.sh new file mode 100755 index 0000000..cd74dda --- /dev/null +++ b/db/provision/verify-connectivity.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# PANELS · comprueba que ESTE ambiente responde: Postgres (6 roles), +# Redis (2 ACL) y Contabo (opcional; obligatorio si REQUIRE_S3=1). +# +# No crea recursos. Carga URLs desde el entorno (source .env.dev-local o +# las vars de Coolify). +# +# Uso: +# set -a && source .env.dev-local && set +a +# ./db/provision/verify-connectivity.sh +# REQUIRE_S3=1 ./db/provision/verify-connectivity.sh # staging/prod +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +fail=0 +check() { + local desc="$1"; shift + if "$@" >/tmp/panels-conn-out.$$ 2>&1; then + echo "OK - $desc" + else + echo "FAIL - $desc" + cat /tmp/panels-conn-out.$$ + fail=1 + fi + rm -f /tmp/panels-conn-out.$$ +} + +need() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "FAIL - falta $name" + fail=1 + return 1 + fi + return 0 +} + +echo "== Postgres ==" +need DATABASE_URL_PLATFORM && check "platform_app SELECT 1" psql "$DATABASE_URL_PLATFORM" -c "SELECT 1;" +need DATABASE_URL_IAM && check "iam_app SELECT 1" psql "$DATABASE_URL_IAM" -c "SELECT 1;" +need DATABASE_URL_CORE && check "core_app SELECT 1" psql "$DATABASE_URL_CORE" -c "SELECT 1;" +if [[ -n "${DATABASE_URL_PLATFORM_OWNER:-}" ]]; then + check "platform_owner SELECT 1" psql "$DATABASE_URL_PLATFORM_OWNER" -c "SELECT 1;" +else + echo "SKIP - DATABASE_URL_PLATFORM_OWNER (solo hace falta para Liquibase)" +fi +if [[ -n "${DATABASE_URL_IAM_OWNER:-}" ]]; then + check "iam_owner SELECT 1" psql "$DATABASE_URL_IAM_OWNER" -c "SELECT 1;" +else + echo "SKIP - DATABASE_URL_IAM_OWNER" +fi +if [[ -n "${DATABASE_URL_CORE_OWNER:-}" ]]; then + check "core_owner SELECT 1" psql "$DATABASE_URL_CORE_OWNER" -c "SELECT 1;" +else + echo "SKIP - DATABASE_URL_CORE_OWNER" +fi + +echo "== Redis ==" +need REDIS_URL_IAM && check "iam redis PING" redis-cli -u "$REDIS_URL_IAM" PING +need REDIS_URL_CORE && check "core redis PING" redis-cli -u "$REDIS_URL_CORE" PING + +echo "== Contabo (S3) ==" +if command -v deno >/dev/null 2>&1; then + if (cd "$ROOT/api" && REQUIRE_S3="${REQUIRE_S3:-}" deno run --allow-net --allow-env --allow-read --allow-write scripts/verify-storage.ts); then + : + else + fail=1 + fi +else + echo "FAIL - deno no está en PATH; no se pudo probar el bucket" + fail=1 +fi + +echo "" +if [[ "$fail" == "0" ]]; then + echo "Conectividad de este ambiente: OK" +else + echo "Hay fallos de conectividad -- no desplegar la API contra este ambiente todavía." + exit 1 +fi diff --git a/docs/coolify.md b/docs/coolify.md index a7e32a6..37b228e 100644 --- a/docs/coolify.md +++ b/docs/coolify.md @@ -81,8 +81,8 @@ En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrde ## Paso a paso en Coolify -1. **Provisionar Postgres y Redis** como recursos gestionados de Coolify para el ambiente (uno de cada, no por módulo). -2. **Aprovisionar roles/esquemas/ACLs**: correr los scripts de [`db/provision/`](../db/provision/README.md) contra ese Postgres/Redis (una vez, desde tu máquina o un job manual -- Coolify no lo hace por ti). +1. **Provisionar Postgres, Redis y bucket** — 1 de cada **por ambiente** (no por módulo; no compartir prod con staging). +2. **Crear accesos y validar**: `./db/provision/create-accesses.sh --apply --verify --out .env..local` contra esos hosts (ver [`db/provision/README.md`](../db/provision/README.md)). Coolify no crea los roles `panels_*` ni los ACL de Redis solo. 3. **Aplicar Liquibase** (paso explícito, NO ocurre al arrancar la app): `./db/update.sh all --context-filter='!dev'` con las credenciales `_owner`. En staging/producción, **nunca** olvidar el `--context-filter` -- sin él, Liquibase corre TAMBIÉN los changesets de demo (`context=dev`). 4. **Bootstrap del primer admin**: `deno run ... api/scripts/bootstrap-admin.ts platform` y `... tenant --tenant-id=... --company-code=...` (ver `db/README.md`). 5. **Push** este repo (sin `.env` ni `data/`). @@ -91,7 +91,7 @@ En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrde 8. **Dominios:** `web-panel` → panels; `web-saas` → saas; `api` sin FQDN (proxy `/v1`). 9. Cargar en Coolify todas las variables **obligatorias** de la tabla de arriba + `COOKIE_SECURE=true`. 10. Deploy. -11. Verificar `https://app…/v1/health` → debe responder `{"ok":true,"core":true,"platform":true,"redis":{"iam":true,"core":true}}`. Si algo es `false`, la app ni siquiera debería haber arrancado (fail-fast, Fase 7). +11. Verificar `https://app…/v1/health` → `ok`, `core`, `platform`, `redis.iam`, `redis.core` y `storage` (en prod `backend:"s3"`). Si algo falla, la API no arranca. 12. Login SaaS `admin` / tu `SEED_PASSWORD`. 13. SMTP en `/smtp` o por `SMTP_*`. From c2ea43818cc7cceff4663a5bc2f59f997a2aaac2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 04:40:54 +0000 Subject: [PATCH 05/13] api: compatibilidad S3 con Cloudflare R2 (reemplazo de Contabo) El SDK de AWS v3 manda checksums CRC32 que R2 no acepta; se calculan solo cuando el API lo exige. Docs y .env.example apuntan a R2. Co-authored-by: alberto.martinez --- .env.example | 8 ++++---- api/storage.ts | 13 +++++++------ docs/coolify.md | 4 ++-- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 2342dbf..67fb668 100644 --- a/.env.example +++ b/.env.example @@ -66,12 +66,12 @@ IAM_REDIS_PASSWORD= CORE_REDIS_PASSWORD= # ============================================================================= -# Contabo Object Storage (Fase 4c) -- expedientes/PDFs/logos cifrados. -# Sin esto, cae a disco local bajo el volumen panel-data (solo dev; en -# producción un volumen local no escala horizontalmente, ver el plan). +# Object storage S3-compatible (Cloudflare R2) -- expedientes/PDFs/logos. +# Sin esto, cae a disco local (solo dev). +# R2: S3_ENDPOINT=https://.r2.cloudflarestorage.com S3_REGION=auto # ============================================================================= S3_ENDPOINT= S3_BUCKET= -S3_REGION= +S3_REGION=auto S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= diff --git a/api/storage.ts b/api/storage.ts index ccde499..0d13e60 100644 --- a/api/storage.ts +++ b/api/storage.ts @@ -17,11 +17,9 @@ import { config, DATA_DIR } from "./config.ts"; * otra capa. El contenido YA viene cifrado con AES-GCM (docs_crypto.ts) * antes de llegar aquí -- el bucket nunca ve texto plano. * - * Backend real: Contabo Object Storage (S3-compatible). Si no hay - * credenciales S3 configuradas (dev local sin Contabo), cae a disco local - * bajo DATA_DIR -- útil para desarrollar/probar sin depender de Contabo, - * pero NO es la ruta recomendada para staging/producción (ver Fase 4c del - * plan: un volumen local no escala horizontalmente). + * Backend real: S3-compatible (Cloudflare R2). Si no hay credenciales S3 + * (dev local), cae a disco bajo DATA_DIR. En staging/producción el volumen + * local no escala: hay que configurar S3_*. */ let client: S3Client | null = null; @@ -72,8 +70,11 @@ function getClient(): S3Client { if (!client) { client = new S3Client({ endpoint: config.s3Endpoint, - region: config.s3Region, + region: config.s3Region || "auto", forcePathStyle: true, + // R2 no implementa los checksums CRC32 que el SDK v3 manda por defecto. + requestChecksumCalculation: "WHEN_REQUIRED", + responseChecksumValidation: "WHEN_REQUIRED", credentials: { accessKeyId: config.s3AccessKeyId, secretAccessKey: config.s3SecretAccessKey, diff --git a/docs/coolify.md b/docs/coolify.md index 37b228e..5a7d17d 100644 --- a/docs/coolify.md +++ b/docs/coolify.md @@ -6,7 +6,7 @@ |-----|--------| | Postgres gestionado en Coolify | **1 instancia** por ambiente, con **2 bases**: `panels_platform`, `panels_product` (esquemas `iam`/`core`) | | Redis gestionado en Coolify | **1 instancia** por ambiente, con **2 usuarios ACL** (`panels_iam_redis`, `panels_core_redis`) | -| Object storage | Contabo Object Storage (S3-compatible) para expedientes/PDFs/logos cifrados | +| Object storage | Cloudflare R2 (S3-compatible) para expedientes/PDFs/logos cifrados | | Contenedores de la app | **3** (`api`, `web-panel`, `web-saas`) | | Volumen persistente | **1** → `/app/data` en `api` (solo fallback local si no hay Contabo configurado -- no usar así en producción) | @@ -65,7 +65,7 @@ openssl rand -hex 24 # passwords de roles Postgres/Redis | `API_KEY` | Auth alternativa por header `X-API-Key` **+ `X-Tenant-Id` obligatorio** (ya no ve todos los tenants, ver revisión de seguridad) | | `VCARD_BASE` | Prefijo QR/vCard gafetes | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `SMTP_FROM` | Correo por env (alternativa al panel `/smtp`) | -| `S3_ENDPOINT` / `S3_BUCKET` / `S3_REGION` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | Contabo Object Storage (Fase 4c). Sin esto, cae a disco local -- **no recomendado en producción** | +| `S3_ENDPOINT` / `S3_BUCKET` / `S3_REGION` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | Cloudflare R2. Endpoint `https://.r2.cloudflarestorage.com`, `S3_REGION=auto`. Sin esto, cae a disco local -- **no recomendado en producción** | `web-panel` y `web-saas` **no** necesitan variables de entorno en runtime (estáticos + proxy nginx). From a45675b6a61979fce4bf111ec0f92ff8d524f0fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 04:54:15 +0000 Subject: [PATCH 06/13] =?UTF-8?q?deploy:=20Liquibase=20autom=C3=A1tico=20e?= =?UTF-8?q?n=20cada=20deploy=20(job=20migrate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coolify usa docker-compose.yml: migrate aplica schema (!dev) y la API espera a que termine. Postgres/Redis locales quedan en docker-compose.local.yml. Co-authored-by: alberto.martinez --- Dockerfile.migrate | 2 +- db/README.md | 6 ++- docker-compose.local.yml | 80 ++++++++++++++++++++++++++++ docker-compose.yml | 109 +++++++++------------------------------ docs/coolify.md | 31 +++++------ package.json | 4 +- 6 files changed, 125 insertions(+), 107 deletions(-) create mode 100644 docker-compose.local.yml diff --git a/Dockerfile.migrate b/Dockerfile.migrate index d258f5c..bc0cf0a 100644 --- a/Dockerfile.migrate +++ b/Dockerfile.migrate @@ -14,4 +14,4 @@ COPY db/ ./db/ RUN chmod +x ./db/bootstrap-tools.sh ./db/update.sh && ./db/bootstrap-tools.sh ENTRYPOINT ["./db/update.sh"] -CMD ["all", "--context-filter=dev"] +CMD ["all", "--context-filter=!dev"] diff --git a/db/README.md b/db/README.md index 6d82773..c8cb95f 100644 --- a/db/README.md +++ b/db/README.md @@ -26,8 +26,10 @@ 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): +En **Coolify** no se corre a mano: el servicio `migrate` de `docker-compose.yml` +aplica `all --context-filter=!dev` en cada deploy, antes de levantar `api`. + +A mano (Java 17+ y credenciales `_owner`, nunca `_app`): ```bash # Local, después de correr db/provision/dev-local.sh: diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..09eb1f4 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,80 @@ +# Desarrollo local: Postgres + Redis en compose, provision y seed `dev`. +# Uso: docker compose -f docker-compose.yml -f docker-compose.local.yml up --build +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD:?define POSTGRES_SUPERUSER_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + expose: + - "5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + expose: + - "6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + + provision: + build: + context: . + dockerfile: Dockerfile.provision + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + PGHOST: postgres + PGPASSWORD: ${POSTGRES_SUPERUSER_PASSWORD:?} + PLATFORM_OWNER_PASSWORD: ${PLATFORM_OWNER_PASSWORD:?} + PLATFORM_APP_PASSWORD: ${PLATFORM_APP_PASSWORD:?} + IAM_OWNER_PASSWORD: ${IAM_OWNER_PASSWORD:?} + IAM_APP_PASSWORD: ${IAM_APP_PASSWORD:?} + CORE_OWNER_PASSWORD: ${CORE_OWNER_PASSWORD:?} + CORE_APP_PASSWORD: ${CORE_APP_PASSWORD:?} + REDIS_ADMIN_URL: redis://redis:6379 + IAM_REDIS_PASSWORD: ${IAM_REDIS_PASSWORD:?} + CORE_REDIS_PASSWORD: ${CORE_REDIS_PASSWORD:?} + command: + - -c + - "./db/provision/docker-provision.sh && ./db/provision/05-redis-acl.sh" + + migrate: + depends_on: + provision: + condition: service_completed_successfully + environment: + DATABASE_URL_PLATFORM_OWNER: postgresql://panels_platform_owner:${PLATFORM_OWNER_PASSWORD:?}@postgres:5432/panels_platform + DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:${IAM_OWNER_PASSWORD:?}@postgres:5432/panels_product + DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:${CORE_OWNER_PASSWORD:?}@postgres:5432/panels_product + command: ["all", "--context-filter=dev"] + + api: + depends_on: + redis: + condition: service_healthy + environment: + DENO_ENV: development + DATABASE_URL_PLATFORM: postgresql://panels_platform_app:${PLATFORM_APP_PASSWORD:?}@postgres:5432/panels_platform + DATABASE_URL_IAM: postgresql://panels_iam_app:${IAM_APP_PASSWORD:?}@postgres:5432/panels_product + DATABASE_URL_CORE: postgresql://panels_core_app:${CORE_APP_PASSWORD:?}@postgres:5432/panels_product + DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:${IAM_OWNER_PASSWORD:?}@postgres:5432/panels_product + DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:${CORE_OWNER_PASSWORD:?}@postgres:5432/panels_product + REDIS_URL_IAM: redis://panels_iam_redis:${IAM_REDIS_PASSWORD:?}@redis:6379 + REDIS_URL_CORE: redis://panels_core_redis:${CORE_REDIS_PASSWORD:?}@redis:6379 + +volumes: + postgres-data: diff --git a/docker-compose.yml b/docker-compose.yml index 62fd415..0d69c5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,74 +1,19 @@ -# Coolify / local — Postgres + Redis (ver plan de migración: monolito -# modular panels_platform / panels_product con esquemas iam+core). -# En Coolify, postgres/redis normalmente son recursos gestionados aparte -# (ver docs/coolify.md); este compose incluye contenedores propios para -# que `docker compose up` funcione standalone en desarrollo local. +# Coolify / producción — Postgres y Redis son recursos gestionados aparte. +# Liquibase corre solo en el job `migrate` (un shot por deploy); la API espera +# a que termine. No arranca Java dentro del contenedor de tráfico. +# +# Local: docker compose -f docker-compose.yml -f docker-compose.local.yml up --build services: - postgres: - image: postgres:16-alpine - restart: unless-stopped - environment: - POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD:?define POSTGRES_SUPERUSER_PASSWORD} - volumes: - - postgres-data:/var/lib/postgresql/data - expose: - - "5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 10 - - redis: - image: redis:7-alpine - restart: unless-stopped - expose: - - "6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 10 - - # Jobs de un solo uso (nunca en producción -- ver db/provision/README.md): - # 1) crea roles/bases/esquemas/ACLs, 2) aplica Liquibase. `api` espera a - # que ambos terminen bien antes de arrancar. - provision: - build: - context: . - dockerfile: Dockerfile.provision - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - environment: - PGHOST: postgres - PGPASSWORD: ${POSTGRES_SUPERUSER_PASSWORD:?} - PLATFORM_OWNER_PASSWORD: ${PLATFORM_OWNER_PASSWORD:?} - PLATFORM_APP_PASSWORD: ${PLATFORM_APP_PASSWORD:?} - IAM_OWNER_PASSWORD: ${IAM_OWNER_PASSWORD:?} - IAM_APP_PASSWORD: ${IAM_APP_PASSWORD:?} - CORE_OWNER_PASSWORD: ${CORE_OWNER_PASSWORD:?} - CORE_APP_PASSWORD: ${CORE_APP_PASSWORD:?} - REDIS_ADMIN_URL: redis://redis:6379 - IAM_REDIS_PASSWORD: ${IAM_REDIS_PASSWORD:?} - CORE_REDIS_PASSWORD: ${CORE_REDIS_PASSWORD:?} - command: - - -c - - "./db/provision/docker-provision.sh && ./db/provision/05-redis-acl.sh" - migrate: build: context: . dockerfile: Dockerfile.migrate - depends_on: - provision: - condition: service_completed_successfully + restart: "no" environment: - DATABASE_URL_PLATFORM_OWNER: postgresql://panels_platform_owner:${PLATFORM_OWNER_PASSWORD:?}@postgres:5432/panels_platform - DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:${IAM_OWNER_PASSWORD:?}@postgres:5432/panels_product - DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:${CORE_OWNER_PASSWORD:?}@postgres:5432/panels_product + DATABASE_URL_PLATFORM_OWNER: ${DATABASE_URL_PLATFORM_OWNER:?} + DATABASE_URL_IAM_OWNER: ${DATABASE_URL_IAM_OWNER:?} + DATABASE_URL_CORE_OWNER: ${DATABASE_URL_CORE_OWNER:?} + command: ["all", "--context-filter=!dev"] api: build: @@ -78,15 +23,14 @@ services: depends_on: migrate: condition: service_completed_successfully - redis: - condition: service_healthy environment: + DENO_ENV: ${DENO_ENV:-production} PORT: ${PORT:-8000} - SESSION_SECRET: ${SESSION_SECRET} - DOCS_KEY: ${DOCS_KEY} - SEED_PASSWORD: ${SEED_PASSWORD} + SESSION_SECRET: ${SESSION_SECRET:?} + DOCS_KEY: ${DOCS_KEY:?} + SEED_PASSWORD: ${SEED_PASSWORD:-} API_KEY: ${API_KEY:-} - PANEL_LOGIN_URL: ${PANEL_LOGIN_URL} + PANEL_LOGIN_URL: ${PANEL_LOGIN_URL:?} COOKIE_SECURE: ${COOKIE_SECURE:-true} CORS_ORIGINS: ${CORS_ORIGINS:-} VCARD_BASE: ${VCARD_BASE:-} @@ -95,21 +39,17 @@ services: SMTP_USER: ${SMTP_USER:-} SMTP_PASS: ${SMTP_PASS:-} SMTP_FROM: ${SMTP_FROM:-} - # Postgres: panels_platform (base separada) + panels_product (iam/core) - DATABASE_URL_PLATFORM: postgresql://panels_platform_app:${PLATFORM_APP_PASSWORD:?}@postgres:5432/panels_platform - DATABASE_URL_IAM: postgresql://panels_iam_app:${IAM_APP_PASSWORD:?}@postgres:5432/panels_product - DATABASE_URL_CORE: postgresql://panels_core_app:${CORE_APP_PASSWORD:?}@postgres:5432/panels_product - DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:${IAM_OWNER_PASSWORD:?}@postgres:5432/panels_product - DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:${CORE_OWNER_PASSWORD:?}@postgres:5432/panels_product - # Redis: ACLs por módulo (ver db/provision/05-redis-acl.sh) - REDIS_URL_IAM: redis://panels_iam_redis:${IAM_REDIS_PASSWORD:?}@redis:6379 - REDIS_URL_CORE: redis://panels_core_redis:${CORE_REDIS_PASSWORD:?}@redis:6379 - # Contabo Object Storage (Fase 4c) -- opcional; sin esto cae a disco - # local bajo el volumen panel-data (no recomendado en producción, - # ver plan: un volumen local no escala horizontalmente). + DATABASE_URL_PLATFORM: ${DATABASE_URL_PLATFORM:?} + DATABASE_URL_PLATFORM_OWNER: ${DATABASE_URL_PLATFORM_OWNER:-} + DATABASE_URL_IAM: ${DATABASE_URL_IAM:?} + DATABASE_URL_IAM_OWNER: ${DATABASE_URL_IAM_OWNER:?} + DATABASE_URL_CORE: ${DATABASE_URL_CORE:?} + DATABASE_URL_CORE_OWNER: ${DATABASE_URL_CORE_OWNER:?} + REDIS_URL_IAM: ${REDIS_URL_IAM:?} + REDIS_URL_CORE: ${REDIS_URL_CORE:?} S3_ENDPOINT: ${S3_ENDPOINT:-} S3_BUCKET: ${S3_BUCKET:-} - S3_REGION: ${S3_REGION:-} + S3_REGION: ${S3_REGION:-auto} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-} volumes: @@ -156,5 +96,4 @@ services: - "80" volumes: - postgres-data: panel-data: diff --git a/docs/coolify.md b/docs/coolify.md index 5a7d17d..23074c6 100644 --- a/docs/coolify.md +++ b/docs/coolify.md @@ -7,7 +7,7 @@ | Postgres gestionado en Coolify | **1 instancia** por ambiente, con **2 bases**: `panels_platform`, `panels_product` (esquemas `iam`/`core`) | | Redis gestionado en Coolify | **1 instancia** por ambiente, con **2 usuarios ACL** (`panels_iam_redis`, `panels_core_redis`) | | Object storage | Cloudflare R2 (S3-compatible) para expedientes/PDFs/logos cifrados | -| Contenedores de la app | **3** (`api`, `web-panel`, `web-saas`) | +| Contenedores de la app | **4** (`migrate` un shot por deploy + `api` + `web-panel` + `web-saas`) | | Volumen persistente | **1** → `/app/data` en `api` (solo fallback local si no hay Contabo configurado -- no usar así en producción) | Los fronts (Alpine + nginx) hacen proxy de `/v1` al servicio `api`, así las cookies de sesión van same-origin. @@ -36,6 +36,7 @@ distintos dentro de `panels_product`, y las reglas del monolito modular. | `SEED_PASSWORD` | string | Password inicial usado por `api/scripts/bootstrap-admin.ts` | | `PANEL_LOGIN_URL` | URL absoluta | Link en correos de acceso | | `DATABASE_URL_PLATFORM` | `postgresql://panels_platform_app:...@host:5432/panels_platform` | Runtime, rol `_app` | +| `DATABASE_URL_PLATFORM_OWNER` | igual, rol `_owner` | Job `migrate` (Liquibase SaaS) | | `DATABASE_URL_IAM` | `postgresql://panels_iam_app:...@host:5432/panels_product` | Runtime, rol `_app`, esquema `iam` | | `DATABASE_URL_CORE` | `postgresql://panels_core_app:...@host:5432/panels_product` | Runtime, rol `_app`, esquema `core` | | `DATABASE_URL_IAM_OWNER` | igual, rol `_owner` | Solo para el lookup de login por username (bypassa RLS a propósito, ver `api/iam_db.ts`) | @@ -83,29 +84,25 @@ En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrde 1. **Provisionar Postgres, Redis y bucket** — 1 de cada **por ambiente** (no por módulo; no compartir prod con staging). 2. **Crear accesos y validar**: `./db/provision/create-accesses.sh --apply --verify --out .env..local` contra esos hosts (ver [`db/provision/README.md`](../db/provision/README.md)). Coolify no crea los roles `panels_*` ni los ACL de Redis solo. -3. **Aplicar Liquibase** (paso explícito, NO ocurre al arrancar la app): `./db/update.sh all --context-filter='!dev'` con las credenciales `_owner`. En staging/producción, **nunca** olvidar el `--context-filter` -- sin él, Liquibase corre TAMBIÉN los changesets de demo (`context=dev`). -4. **Bootstrap del primer admin**: `deno run ... api/scripts/bootstrap-admin.ts platform` y `... tenant --tenant-id=... --company-code=...` (ver `db/README.md`). -5. **Push** este repo (sin `.env` ni `data/`). -6. Coolify → **New Resource → Docker Compose** → `docker-compose.yml` (solo para `api`/`web-panel`/`web-saas` -- si Postgres/Redis ya son recursos gestionados aparte, quitar esos servicios del compose antes de desplegar, o apuntar sus variables a los recursos gestionados en vez de los contenedores locales del compose). -7. **Persistent storage:** volumen `panel-data` → `/app/data` en `api` (solo fallback de archivos si no hay Contabo). +3. **Coolify → Docker Compose** → `docker-compose.yml` (este archivo **no** levanta Postgres/Redis; usa los recursos que ya creaste). Conecta el stack a la **misma red** que Postgres y Redis (Connect to Predefined Network). +4. Cargar en el recurso compose **todas** las variables obligatorias (incluidas las 3 `DATABASE_URL_*_OWNER`: las usa el job `migrate`). `DENO_ENV=production`. +5. **Deploy.** En cada deploy corre `migrate` (`--context-filter=!dev`, sin demo) y **después** arranca `api`. No hace falta entrar al VPS a correr Liquibase. +6. **Bootstrap del primer admin** (solo la primera vez): `bootstrap-admin.ts` (ver `db/README.md`). +7. **Persistent storage:** volumen `panel-data` → `/app/data` en `api` (solo fallback si no hay R2). 8. **Dominios:** `web-panel` → panels; `web-saas` → saas; `api` sin FQDN (proxy `/v1`). -9. Cargar en Coolify todas las variables **obligatorias** de la tabla de arriba + `COOKIE_SECURE=true`. -10. Deploy. -11. Verificar `https://app…/v1/health` → `ok`, `core`, `platform`, `redis.iam`, `redis.core` y `storage` (en prod `backend:"s3"`). Si algo falla, la API no arranca. -12. Login SaaS `admin` / tu `SEED_PASSWORD`. -13. SMTP en `/smtp` o por `SMTP_*`. +9. Verificar `https://app…/v1/health` → `ok`, `core`, `platform`, `redis.iam`, `redis.core` y `storage` (en prod `backend:"s3"`). Si algo falla, la API no arranca. +10. Login SaaS `admin` / tu `SEED_PASSWORD` (tras el bootstrap). +11. SMTP en `/smtp` o por `SMTP_*`. ## Local (todo en docker-compose, incluyendo Postgres/Redis propios) ```bash -cp .env.example .env # rellenar TODAS las variables (incluye los passwords de roles Postgres/Redis) -docker compose up --build +cp .env.example .env +docker compose -f docker-compose.yml -f docker-compose.local.yml up --build ``` -El compose local incluye: `provision` (roles/esquemas/ACLs, un solo uso) → -`migrate` (Liquibase con datos de demo, un solo uso) → `api`/`web-panel`/`web-saas`. -Para producción, `provision`/`migrate` NO se corren así -- ver -`db/provision/README.md` y `db/RUNBOOK-corte.md`. +El overlay local añade Postgres/Redis + `provision` y corre Liquibase con `context=dev` (demo). +En Coolify solo se usa `docker-compose.yml`: `migrate` con `!dev`. Sin las variables obligatorias, compose **no arranca** (`:?` en docker-compose.yml). diff --git a/package.json b/package.json index b5334d8..a5cc433 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dev:web": "cd web-panel && npm run dev", "dev:saas": "cd web-saas && npm run dev", "db:migrate": "./db/update.sh all", - "docker:build": "docker compose build", - "docker:up": "docker compose up -d" + "docker:build": "docker compose -f docker-compose.yml -f docker-compose.local.yml build", + "docker:up": "docker compose -f docker-compose.yml -f docker-compose.local.yml up -d" } } From 4f20c98e5d29c77a217e3daa9d827df95d6dec70 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:11:36 +0000 Subject: [PATCH 07/13] docs: host interno de Coolify, no el ID del contenedor Postgres Liquibase falla con UnknownHostException si DATABASE_URL usa el CONTAINER ID (p. ej. c72dde7d6b47) en lugar del hostname de Postgres URL (internal). Co-authored-by: alberto.martinez --- docs/coolify.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/coolify.md b/docs/coolify.md index 23074c6..b9414e7 100644 --- a/docs/coolify.md +++ b/docs/coolify.md @@ -94,6 +94,16 @@ En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrde 10. Login SaaS `admin` / tu `SEED_PASSWORD` (tras el bootstrap). 11. SMTP en `/smtp` o por `SMTP_*`. +## Troubleshooting: `UnknownHostException` / Liquibase no conecta + +El host de las 6 `DATABASE_URL_*` y de las 2 `REDIS_URL_*` **no** es el ID corto del contenedor (`docker exec -it c72dde7d6b47 …`). Docker DNS en Coolify **no** resuelve ese ID; Liquibase falla con `UnknownHostException`. + +Usa el hostname de **Postgres URL (internal)** / **Redis URL (internal)** en Coolify: el segmento entre `@` y `:5432` (o `:6379`). Suele ser un UUID o el nombre de servicio de Coolify, no los 12 caracteres del `CONTAINER ID`. + +Además, el recurso Docker Compose de PANELS debe estar en la **misma red** que Postgres y Redis (Connect to Predefined Network, a menudo `coolify`). Si no, el hostname interno existe pero el contenedor `migrate` no lo ve. + +Tras corregir las URLs, redesplegar y revisar `docker logs` del contenedor `migrate-*` (debe terminar con Liquibase `Update has been successful` o similar, exit 0). El `docker stop … No such container` del helper de Coolify es ruido de limpieza, no la causa. + ## Local (todo en docker-compose, incluyendo Postgres/Redis propios) ```bash From 05ea4e942f445886634960256372068a948233d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:19:10 +0000 Subject: [PATCH 08/13] ops: sacar el overlay local del glob docker-compose*.yml Coolify Reload Compose mezclaba docker-compose.local.yml y marcaba PLATFORM_*_PASSWORD / IAM_* / CORE_* / *_REDIS_PASSWORD como Required. Esas variables son solo para provision local; en Coolify van las URLs. Co-authored-by: alberto.martinez --- docker-compose.yml | 3 ++- docs/coolify.md | 8 ++++++-- docker-compose.local.yml => overlays/compose.local.yml | 3 ++- package.json | 4 ++-- 4 files changed, 12 insertions(+), 6 deletions(-) rename docker-compose.local.yml => overlays/compose.local.yml (94%) diff --git a/docker-compose.yml b/docker-compose.yml index 0d69c5e..54d0b40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,8 @@ # Liquibase corre solo en el job `migrate` (un shot por deploy); la API espera # a que termine. No arranca Java dentro del contenedor de tráfico. # -# Local: docker compose -f docker-compose.yml -f docker-compose.local.yml up --build +# Coolify: un solo archivo — docker-compose.yml. No añadir overlays. +# Local: docker compose -f docker-compose.yml -f overlays/compose.local.yml up --build services: migrate: build: diff --git a/docs/coolify.md b/docs/coolify.md index b9414e7..a50dd10 100644 --- a/docs/coolify.md +++ b/docs/coolify.md @@ -84,7 +84,7 @@ En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrde 1. **Provisionar Postgres, Redis y bucket** — 1 de cada **por ambiente** (no por módulo; no compartir prod con staging). 2. **Crear accesos y validar**: `./db/provision/create-accesses.sh --apply --verify --out .env..local` contra esos hosts (ver [`db/provision/README.md`](../db/provision/README.md)). Coolify no crea los roles `panels_*` ni los ACL de Redis solo. -3. **Coolify → Docker Compose** → `docker-compose.yml` (este archivo **no** levanta Postgres/Redis; usa los recursos que ya creaste). Conecta el stack a la **misma red** que Postgres y Redis (Connect to Predefined Network). +3. **Coolify → Docker Compose** → **solo** `docker-compose.yml` (Compose Location). **No** añadas `overlays/compose.local.yml` ni ningún `docker-compose.local.yml`. El overlay local pide `PLATFORM_*_PASSWORD` etc.; en Coolify esas 8 variables **no** se rellenan: los accesos van en las `DATABASE_URL_*` / `REDIS_URL_*` ya expandidas. Este archivo **no** levanta Postgres/Redis; usa los recursos que ya creaste. Conecta el stack a la **misma red** que Postgres y Redis (Connect to Predefined Network). 4. Cargar en el recurso compose **todas** las variables obligatorias (incluidas las 3 `DATABASE_URL_*_OWNER`: las usa el job `migrate`). `DENO_ENV=production`. 5. **Deploy.** En cada deploy corre `migrate` (`--context-filter=!dev`, sin demo) y **después** arranca `api`. No hace falta entrar al VPS a correr Liquibase. 6. **Bootstrap del primer admin** (solo la primera vez): `bootstrap-admin.ts` (ver `db/README.md`). @@ -104,11 +104,15 @@ Además, el recurso Docker Compose de PANELS debe estar en la **misma red** que Tras corregir las URLs, redesplegar y revisar `docker logs` del contenedor `migrate-*` (debe terminar con Liquibase `Update has been successful` o similar, exit 0). El `docker stop … No such container` del helper de Coolify es ruido de limpieza, no la causa. +### Coolify pide `PLATFORM_OWNER_PASSWORD`, `IAM_APP_PASSWORD`, `*_REDIS_PASSWORD`… + +Eso sale de **Reload Compose** mezclando el overlay de desarrollo. En producción **no las rellenes**. En el recurso: Compose file = `docker-compose.yml` únicamente. Borra esas 8 variables si Coolify las marcó Required. Siguen haciendo falta las URLs completas (`DATABASE_URL_*`, `REDIS_URL_*`). + ## Local (todo en docker-compose, incluyendo Postgres/Redis propios) ```bash cp .env.example .env -docker compose -f docker-compose.yml -f docker-compose.local.yml up --build +docker compose -f docker-compose.yml -f overlays/compose.local.yml up --build ``` El overlay local añade Postgres/Redis + `provision` y corre Liquibase con `context=dev` (demo). diff --git a/docker-compose.local.yml b/overlays/compose.local.yml similarity index 94% rename from docker-compose.local.yml rename to overlays/compose.local.yml index 09eb1f4..acdadbc 100644 --- a/docker-compose.local.yml +++ b/overlays/compose.local.yml @@ -1,5 +1,6 @@ # Desarrollo local: Postgres + Redis en compose, provision y seed `dev`. -# Uso: docker compose -f docker-compose.yml -f docker-compose.local.yml up --build +# NO usar en Coolify (vive fuera de docker-compose*.yml para que Reload no lo mezcle). +# Uso: docker compose -f docker-compose.yml -f overlays/compose.local.yml up --build services: postgres: image: postgres:16-alpine diff --git a/package.json b/package.json index a5cc433..85867f0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dev:web": "cd web-panel && npm run dev", "dev:saas": "cd web-saas && npm run dev", "db:migrate": "./db/update.sh all", - "docker:build": "docker compose -f docker-compose.yml -f docker-compose.local.yml build", - "docker:up": "docker compose -f docker-compose.yml -f docker-compose.local.yml up -d" + "docker:build": "docker compose -f docker-compose.yml -f overlays/compose.local.yml build", + "docker:up": "docker compose -f docker-compose.yml -f overlays/compose.local.yml up -d" } } From 05d1a8c13373ad4ad8a04e8026c0e473384816a1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:24:51 +0000 Subject: [PATCH 09/13] migrate: rechazar CONTAINER ID y fallar si el host no resuelve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coolify no imprime stdout de Liquibase; el job migrate seguía saliendo 1 por DNS. Preflight deja el host JDBC en los logs del contenedor y corta si parece un ID de 12 caracteres. Co-authored-by: alberto.martinez --- db/update.sh | 42 ++++++++++++++++++++++++++++++++++++++++++ docs/coolify.md | 10 +++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/db/update.sh b/db/update.sh index 43ece0a..042c0c1 100755 --- a/db/update.sh +++ b/db/update.sh @@ -9,6 +9,7 @@ # DATABASE_URL_CORE_OWNER (jdbc: panels_product, schema core) # # Formato esperado: postgresql://usuario:password@host:puerto/basededatos +# (también se acepta postgres://). El host NO puede ser un CONTAINER ID. # # Uso: # set -a && source .env.dev-local && set +a && ./db/update.sh all @@ -39,12 +40,52 @@ done # postgresql://user:pass@host:port/db -> jdbc:postgresql://host:port/db + user/pass parse_url() { local url="$1" + url="${url#"${url%%[![:space:]]*}"}" + url="${url%"${url##*[![:space:]]}"}" + url="${url#\'}"; url="${url%\'}" + url="${url#\"}"; url="${url%\"}" + if [[ "$url" == postgres://* ]]; then + url="postgresql://${url#postgres://}" + fi + if [[ "$url" != postgresql://* ]]; then + echo "ERROR: la URL debe empezar por postgresql:// o postgres://" >&2 + exit 1 + fi local rest="${url#postgresql://}" local userinfo="${rest%%@*}" local hostpart="${rest#*@}" + if [[ "$userinfo" == "$rest" || -z "$hostpart" ]]; then + echo "ERROR: URL sin host (falta user:pass@host)" >&2 + exit 1 + fi PARSED_USER="${userinfo%%:*}" PARSED_PASS="${userinfo#*:}" PARSED_JDBC="jdbc:postgresql://${hostpart}" + local hp="${hostpart%%\?*}" + hp="${hp%%/*}" + PARSED_HOST="${hp%%:*}" +} + +preflight_host() { + echo "==> host JDBC: ${PARSED_HOST}" + if [[ "$PARSED_HOST" =~ ^[0-9a-f]{12}$ ]]; then + echo "ERROR: '${PARSED_HOST}' es un CONTAINER ID de Docker, no un hostname." >&2 + echo "En Coolify copia Postgres URL (internal): el texto entre @ y :5432." >&2 + echo "Pon ese hostname en las 6 DATABASE_URL_* (mismo host) y conecta el" >&2 + echo "compose a la red predefined 'coolify'." >&2 + exit 1 + fi + if [[ "$PARSED_HOST" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + return 0 + fi + if command -v getent >/dev/null 2>&1; then + if ! getent hosts "$PARSED_HOST" >/dev/null 2>&1; then + echo "ERROR: no se resuelve el host '${PARSED_HOST}' (DNS)." >&2 + echo "Usa el hostname de Postgres URL (internal), no el ID del contenedor." >&2 + echo "El stack PANELS debe estar en la misma red Docker que Postgres (coolify)." >&2 + exit 1 + fi + fi } run_one() { @@ -57,6 +98,7 @@ run_one() { fi parse_url "$url" echo "==> Liquibase $LB_COMMAND: $name ($PARSED_JDBC)" + preflight_host ( cd "$ROOT/db/$name" "$LIQUIBASE" \ diff --git a/docs/coolify.md b/docs/coolify.md index a50dd10..2c3f161 100644 --- a/docs/coolify.md +++ b/docs/coolify.md @@ -102,7 +102,15 @@ Usa el hostname de **Postgres URL (internal)** / **Redis URL (internal)** en Coo Además, el recurso Docker Compose de PANELS debe estar en la **misma red** que Postgres y Redis (Connect to Predefined Network, a menudo `coolify`). Si no, el hostname interno existe pero el contenedor `migrate` no lo ve. -Tras corregir las URLs, redesplegar y revisar `docker logs` del contenedor `migrate-*` (debe terminar con Liquibase `Update has been successful` o similar, exit 0). El `docker stop … No such container` del helper de Coolify es ruido de limpieza, no la causa. +El log de deploy de Coolify **no** incluye stdout de Liquibase. Si `service "migrate" didn't complete successfully: exit 1`, el error real está en: + +```bash +sudo docker logs migrate-- +``` + +Ejemplo de este entorno: `sudo docker logs migrate-pdyrt8ccp804tfmutefau0k6-052025710094`. Debe verse `==> host JDBC: …` y el error de Liquibase. Un host de 12 caracteres hex (`c72dde7d6b47`) es el CONTAINER ID — cámbialo. `Liquibase OK` + exit 0 = bien. + +El `docker stop … No such container` del helper de Coolify es ruido de limpieza, no la causa. El build de imágenes puede ser OK y el deploy igual falla en `migrate`. ### Coolify pide `PLATFORM_OWNER_PASSWORD`, `IAM_APP_PASSWORD`, `*_REDIS_PASSWORD`… From b68313c5ee6781b12b2bdfce2411abf44bc0d6d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:25:53 +0000 Subject: [PATCH 10/13] compose: unir migrate y api a la red coolify El host qzegekm3sr2bevgxl4wh4th2 es el UUID interno de Postgres; solo resuelve en esa red. Sin ella Liquibase lanza UnknownHostException. Co-authored-by: alberto.martinez --- docker-compose.yml | 14 ++++++++++++++ docs/coolify.md | 14 +++++++++++--- overlays/compose.local.yml | 6 ++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 54d0b40..9b28b43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,12 +4,18 @@ # # Coolify: un solo archivo — docker-compose.yml. No añadir overlays. # Local: docker compose -f docker-compose.yml -f overlays/compose.local.yml up --build +# +# `data` = red donde viven Postgres/Redis de Coolify (UUID tipo +# qzegekm3sr2bevgxl4wh4th2). Sin esta red, Liquibase da UnknownHostException. services: migrate: build: context: . dockerfile: Dockerfile.migrate restart: "no" + networks: + - default + - data environment: DATABASE_URL_PLATFORM_OWNER: ${DATABASE_URL_PLATFORM_OWNER:?} DATABASE_URL_IAM_OWNER: ${DATABASE_URL_IAM_OWNER:?} @@ -53,6 +59,9 @@ services: S3_REGION: ${S3_REGION:-auto} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-} + networks: + - default + - data volumes: - panel-data:/app/data expose: @@ -98,3 +107,8 @@ services: volumes: panel-data: + +networks: + data: + name: ${PANELS_DOCKER_NETWORK:-coolify} + external: true diff --git a/docs/coolify.md b/docs/coolify.md index 2c3f161..f8c94a1 100644 --- a/docs/coolify.md +++ b/docs/coolify.md @@ -98,9 +98,17 @@ En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrde El host de las 6 `DATABASE_URL_*` y de las 2 `REDIS_URL_*` **no** es el ID corto del contenedor (`docker exec -it c72dde7d6b47 …`). Docker DNS en Coolify **no** resuelve ese ID; Liquibase falla con `UnknownHostException`. -Usa el hostname de **Postgres URL (internal)** / **Redis URL (internal)** en Coolify: el segmento entre `@` y `:5432` (o `:6379`). Suele ser un UUID o el nombre de servicio de Coolify, no los 12 caracteres del `CONTAINER ID`. +Usa el hostname de **Postgres URL (internal)** / **Redis URL (internal)** en Coolify: el segmento entre `@` y `:5432` (o `:6379`). Un UUID tipo `qzegekm3sr2bevgxl4wh4th2` **es el host correcto**; un CONTAINER ID de 12 caracteres (`c72dde7d6b47`) no. -Además, el recurso Docker Compose de PANELS debe estar en la **misma red** que Postgres y Redis (Connect to Predefined Network, a menudo `coolify`). Si no, el hostname interno existe pero el contenedor `migrate` no lo ve. +Ese UUID solo resuelve en la red Docker **`coolify`**. `docker-compose.yml` une `migrate` y `api` a esa red (`networks.data`). En Coolify deja también **Connect to Predefined Network**. Redis tiene **otro** UUID (el de Redis URL internal), no el de Postgres. + +Si tras unir la red sigue `UnknownHostException`, en el VPS: + +```bash +sudo docker network inspect coolify | grep -E 'Name|Aliases|qzegekm3' +``` + +Usa el alias que aparezca junto al contenedor de Postgres (a veces `postgres-`). El log de deploy de Coolify **no** incluye stdout de Liquibase. Si `service "migrate" didn't complete successfully: exit 1`, el error real está en: @@ -108,7 +116,7 @@ El log de deploy de Coolify **no** incluye stdout de Liquibase. Si `service "mig sudo docker logs migrate-- ``` -Ejemplo de este entorno: `sudo docker logs migrate-pdyrt8ccp804tfmutefau0k6-052025710094`. Debe verse `==> host JDBC: …` y el error de Liquibase. Un host de 12 caracteres hex (`c72dde7d6b47`) es el CONTAINER ID — cámbialo. `Liquibase OK` + exit 0 = bien. +Ejemplo: `sudo docker logs migrate-pdyrt8ccp804tfmutefau0k6-052025710094`. `UnknownHostException` con un UUID largo = `migrate` fuera de `coolify`, no un host mal copiado. `Liquibase OK` + exit 0 = bien. El `docker stop … No such container` del helper de Coolify es ruido de limpieza, no la causa. El build de imágenes puede ser OK y el deploy igual falla en `migrate`. diff --git a/overlays/compose.local.yml b/overlays/compose.local.yml index acdadbc..e3b5354 100644 --- a/overlays/compose.local.yml +++ b/overlays/compose.local.yml @@ -79,3 +79,9 @@ services: volumes: postgres-data: + +# El compose de Coolify exige la red externa `coolify`. En local no existe: +# esta red puente sustituye `data` (Compose 2.24+ !override). +networks: + data: !override + name: panels-local-data From ccf86c5b2e966d125f94649efab7cfc915fecab3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:32:06 +0000 Subject: [PATCH 11/13] api: etiquetar fallos de arranque (postgres, redis, s3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tras Liquibase, Coolify marca api unhealthy si el fail-fast tira antes de Deno.serve. Los logs ahora dicen qué ping falló. Co-authored-by: alberto.martinez --- api/main.ts | 30 ++++++++++++++++++++++-------- docs/coolify.md | 14 ++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/api/main.ts b/api/main.ts index 614a157..002dfa9 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1252,22 +1252,36 @@ registerPayrollRoutes(app); const port = config.port; +function startup(name: string, p: Promise): Promise { + return p.then(() => { + console.log(`[startup] ok ${name}`); + }).catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e); + console.error(`[startup] FAIL ${name}: ${msg}`); + throw e; + }); +} + // Fail-fast (Fase 7): antes con SQLite la app "siempre arrancaba" (creaba // el archivo si no existía). Con Postgres/Redis, si alguna de las 5 // conexiones no responde al arrancar, es mejor fallar ruidosamente que // dejar que el primer request autenticado descubra el problema. await Promise.all([ - pingCoreDb(), - pingPlatformDb(), - pingRedis().then((r) => { - if (!r.iam || !r.core) throw new Error("Redis (iam/core) no responde"); - }), - pingStorage().then((s) => { - if (!s.ok) throw new Error("Storage (Contabo o disco local) no responde"); + startup("postgres core (DATABASE_URL_CORE)", pingCoreDb()), + startup("postgres platform (DATABASE_URL_PLATFORM)", pingPlatformDb()), + startup("redis iam+core", pingRedis().then((r) => { + if (!r.iam || !r.core) { + throw new Error( + `Redis no responde (iam=${r.iam} core=${r.core}). REDIS_URL_* debe usar el hostname de Redis URL (internal), no el UUID de Postgres.`, + ); + } + })), + startup("storage S3/R2", pingStorage().then((s) => { + if (!s.ok) throw new Error("Storage no responde (HeadBucket R2 o disco local)"); if (!config.isDev && !s.configured) { throw new Error("S3_ENDPOINT/S3_BUCKET/S3_* son obligatorios fuera de desarrollo"); } - }), + })), ]); Deno.serve({ port, hostname: "0.0.0.0" }, app.fetch); diff --git a/docs/coolify.md b/docs/coolify.md index f8c94a1..e844f4c 100644 --- a/docs/coolify.md +++ b/docs/coolify.md @@ -120,6 +120,20 @@ Ejemplo: `sudo docker logs migrate-pdyrt8ccp804tfmutefau0k6-052025710094`. `Unkn El `docker stop … No such container` del helper de Coolify es ruido de limpieza, no la causa. El build de imágenes puede ser OK y el deploy igual falla en `migrate`. +### `dependency api failed to start` / `api is unhealthy` + +Liquibase ya corrió si ves `migrate … Exited` y acto seguido `api … Starting`. La API **no llega a escuchar** si Redis, S3 o los roles `_app` fallan (fail-fast antes de `Deno.serve`). Coolify entonces marca unhealthy en 1–2 s. + +```bash +sudo docker logs api-- +``` + +Busca `[startup] FAIL …`. Causas típicas: + +- **Redis:** `REDIS_URL_IAM` / `REDIS_URL_CORE` con el UUID de **Postgres**. Redis tiene el suyo (Redis URL internal). +- **S3/R2:** faltan `S3_*` o HeadBucket falla (token Account API, Object Read & Write, bucket correcto, `S3_REGION=auto`). +- **Postgres `_app`:** Liquibase usa `*_OWNER`; el runtime usa `DATABASE_URL_PLATFORM`, `DATABASE_URL_IAM`, `DATABASE_URL_CORE` (passwords distintos). + ### Coolify pide `PLATFORM_OWNER_PASSWORD`, `IAM_APP_PASSWORD`, `*_REDIS_PASSWORD`… Eso sale de **Reload Compose** mezclando el overlay de desarrollo. En producción **no las rellenes**. En el recurso: Compose file = `docker-compose.yml` únicamente. Borra esas 8 variables si Coolify las marcó Required. Siguen haciendo falta las URLs completas (`DATABASE_URL_*`, `REDIS_URL_*`). From ab20701fea7211014b1448acc7ba33380e67d4e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:42:13 +0000 Subject: [PATCH 12/13] api: ping R2 con ListObjects, no HeadBucket Cloudflare R2 suele devolver 403 en HeadBucket con token acotado al bucket, y eso tumba el fail-fast de arranque. El ping ahora lista o escribe una sonda y registra el error S3. Co-authored-by: alberto.martinez --- api/config.ts | 8 +++--- api/main.ts | 4 ++- api/scripts/verify-storage.ts | 7 ++--- api/storage.ts | 49 ++++++++++++++++++++++++++++++----- docs/coolify.md | 2 +- 5 files changed, 54 insertions(+), 16 deletions(-) diff --git a/api/config.ts b/api/config.ts index 6f57396..6dcbb84 100644 --- a/api/config.ts +++ b/api/config.ts @@ -78,10 +78,10 @@ export const config = { redisUrlCore: isDev ? (Deno.env.get("REDIS_URL_CORE") ?? "") : required("REDIS_URL_CORE"), // --- Contabo Object Storage (S3-compatible) -- expedientes/PDFs/logos --- - s3Endpoint: Deno.env.get("S3_ENDPOINT") ?? "", - s3Bucket: Deno.env.get("S3_BUCKET") ?? "", - s3Region: Deno.env.get("S3_REGION") ?? "us-east-1", - s3AccessKeyId: Deno.env.get("S3_ACCESS_KEY_ID") ?? "", + s3Endpoint: (Deno.env.get("S3_ENDPOINT") ?? "").trim().replace(/\/$/, ""), + s3Bucket: (Deno.env.get("S3_BUCKET") ?? "").trim(), + s3Region: (Deno.env.get("S3_REGION") ?? "auto").trim() || "auto", + s3AccessKeyId: (Deno.env.get("S3_ACCESS_KEY_ID") ?? "").trim(), s3SecretAccessKey: Deno.env.get("S3_SECRET_ACCESS_KEY") ?? "", isDev, diff --git a/api/main.ts b/api/main.ts index 002dfa9..0ac52b5 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1277,7 +1277,9 @@ await Promise.all([ } })), startup("storage S3/R2", pingStorage().then((s) => { - if (!s.ok) throw new Error("Storage no responde (HeadBucket R2 o disco local)"); + if (!s.ok) { + throw new Error(`Storage no responde: ${s.error ?? "sin detalle"}`); + } if (!config.isDev && !s.configured) { throw new Error("S3_ENDPOINT/S3_BUCKET/S3_* son obligatorios fuera de desarrollo"); } diff --git a/api/scripts/verify-storage.ts b/api/scripts/verify-storage.ts index c80c5ad..d2c2c81 100644 --- a/api/scripts/verify-storage.ts +++ b/api/scripts/verify-storage.ts @@ -1,5 +1,6 @@ /** - * Sonda de Contabo (S3): HeadBucket + put/get/delete. + * Sonda S3/R2: ListObjects (o put/get/delete). HeadBucket no se usa: + * R2 suele devolver 403 con tokens acotados al bucket. * * Desde api/: * deno run --allow-net --allow-env --allow-read --allow-write scripts/verify-storage.ts @@ -27,9 +28,9 @@ if (!s3Configured()) { console.log(`S3 endpoint=${config.s3Endpoint} bucket=${config.s3Bucket} region=${config.s3Region}`); const ping = await pingStorage(); if (!ping.ok) { - console.error("FAIL - HeadBucket: no se pudo alcanzar el bucket (credenciales, red o nombre)."); + console.error(`FAIL - ping bucket: ${ping.error ?? "sin detalle"}`); Deno.exit(1); } -console.log("OK - HeadBucket"); +console.log("OK - ping bucket (ListObjects o sonda write)"); await probeStorageReadWrite(); console.log("OK - put/get/delete de objeto sonda"); diff --git a/api/storage.ts b/api/storage.ts index 0d13e60..79aedb1 100644 --- a/api/storage.ts +++ b/api/storage.ts @@ -2,7 +2,7 @@ import { S3Client, PutObjectCommand, GetObjectCommand, - HeadBucketCommand, + ListObjectsV2Command, DeleteObjectCommand, } from "npm:@aws-sdk/client-s3@3"; import { mkdir, readFile, writeFile } from "node:fs/promises"; @@ -32,23 +32,58 @@ export type StoragePing = { configured: boolean; ok: boolean; backend: "s3" | "local"; + error?: string; }; -/** HeadBucket (S3) o escritura de prueba en disco local. */ +function s3Err(e: unknown): string { + if (e && typeof e === "object") { + const o = e as { + name?: string; + message?: string; + Code?: string; + $metadata?: { httpStatusCode?: number }; + }; + return [o.name, o.Code, o.$metadata?.httpStatusCode, o.message].filter(Boolean).join(": "); + } + return String(e); +} + +/** + * R2: HeadBucket suele dar 403 con token de Account API acotado al bucket. + * ListObjectsV2 (o un put/get sonda) es lo que realmente usamos en runtime. + */ export async function pingStorage(): Promise { if (!s3Configured()) { try { await mkdir(join(DATA_DIR, "local-objects"), { recursive: true }); return { configured: false, ok: true, backend: "local" }; - } catch { - return { configured: false, ok: false, backend: "local" }; + } catch (e) { + return { configured: false, ok: false, backend: "local", error: s3Err(e) }; } } try { - await getClient().send(new HeadBucketCommand({ Bucket: config.s3Bucket })); + await getClient().send( + new ListObjectsV2Command({ Bucket: config.s3Bucket, MaxKeys: 1 }), + ); return { configured: true, ok: true, backend: "s3" }; - } catch { - return { configured: true, ok: false, backend: "s3" }; + } catch (listErr) { + try { + await probeStorageReadWrite(); + return { configured: true, ok: true, backend: "s3" }; + } catch (writeErr) { + const error = `list=${s3Err(listErr)}; write=${s3Err(writeErr)}`; + console.error( + "[storage] ping failed", + error, + "endpoint=", + config.s3Endpoint, + "bucket=", + config.s3Bucket, + "region=", + config.s3Region, + ); + return { configured: true, ok: false, backend: "s3", error }; + } } } diff --git a/docs/coolify.md b/docs/coolify.md index e844f4c..220bea7 100644 --- a/docs/coolify.md +++ b/docs/coolify.md @@ -131,7 +131,7 @@ sudo docker logs api-- Busca `[startup] FAIL …`. Causas típicas: - **Redis:** `REDIS_URL_IAM` / `REDIS_URL_CORE` con el UUID de **Postgres**. Redis tiene el suyo (Redis URL internal). -- **S3/R2:** faltan `S3_*` o HeadBucket falla (token Account API, Object Read & Write, bucket correcto, `S3_REGION=auto`). +- **S3/R2:** endpoint `https://.r2.cloudflarestorage.com` (sin barra final, **sin** el nombre del bucket), `S3_REGION=auto`, token **Account API** con Object Read & Write al bucket. R2 a menudo responde 403 a HeadBucket; el ping de la API usa ListObjects. - **Postgres `_app`:** Liquibase usa `*_OWNER`; el runtime usa `DATABASE_URL_PLATFORM`, `DATABASE_URL_IAM`, `DATABASE_URL_CORE` (passwords distintos). ### Coolify pide `PLATFORM_OWNER_PASSWORD`, `IAM_APP_PASSWORD`, `*_REDIS_PASSWORD`… From 42bba3446981bd3dcd5953642210f180add4dcf7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:49:14 +0000 Subject: [PATCH 13/13] api: --allow-sys para el SDK de AWS/R2 en Deno MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El cliente S3 lee osRelease; sin --allow-sys el ping a R2 falla con NotCapable aunque endpoint y bucket estén bien. Co-authored-by: alberto.martinez --- .github/workflows/ci.yml | 2 +- Dockerfile.api | 3 ++- api/deno.json | 6 +++--- api/scripts/verify-storage.ts | 2 +- db/provision/verify-connectivity.sh | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8428850..62c870b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,7 @@ jobs: working-directory: api env: DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:ci-core-owner@localhost:5432/panels_product - run: deno test --allow-net --allow-read --allow-write --allow-env + run: deno test --allow-net --allow-read --allow-write --allow-env --allow-sys web: runs-on: ubuntu-latest diff --git a/Dockerfile.api b/Dockerfile.api index bb2b92d..9405e81 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -20,4 +20,5 @@ EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD ["deno", "eval", "const r=await fetch('http://127.0.0.1:'+(Deno.env.get('PORT')||'8000')+'/v1/health'); if(!r.ok) Deno.exit(1)"] -CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "main.ts"] +# --allow-sys: AWS SDK v3 (R2) lee osRelease; sin esto Deno lanza NotCapable. +CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "--allow-sys", "main.ts"] diff --git a/api/deno.json b/api/deno.json index 0dd5e86..c8a08e3 100644 --- a/api/deno.json +++ b/api/deno.json @@ -2,11 +2,11 @@ "name": "panel-obra-api", "exports": "./main.ts", "tasks": { - "dev": "deno run --allow-net --allow-read --allow-write --allow-env --env-file=../.env main.ts", - "start": "deno run --allow-net --allow-read --allow-write --allow-env main.ts", + "dev": "deno run --allow-net --allow-read --allow-write --allow-env --allow-sys --env-file=../.env main.ts", + "start": "deno run --allow-net --allow-read --allow-write --allow-env --allow-sys main.ts", "migrate": "cd .. && ./db/update.sh all", "check": "deno check main.ts", - "test": "deno test --allow-net --allow-read --allow-write --allow-env" + "test": "deno test --allow-net --allow-read --allow-write --allow-env --allow-sys" }, "imports": { "hono": "jsr:@hono/hono@4", diff --git a/api/scripts/verify-storage.ts b/api/scripts/verify-storage.ts index d2c2c81..e2ec966 100644 --- a/api/scripts/verify-storage.ts +++ b/api/scripts/verify-storage.ts @@ -3,7 +3,7 @@ * R2 suele devolver 403 con tokens acotados al bucket. * * Desde api/: - * deno run --allow-net --allow-env --allow-read --allow-write scripts/verify-storage.ts + * deno run --allow-net --allow-env --allow-read --allow-write --allow-sys scripts/verify-storage.ts * * Requiere S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY. * Si REQUIRE_S3=1 y no hay credenciales, sale con error (staging/prod). diff --git a/db/provision/verify-connectivity.sh b/db/provision/verify-connectivity.sh index cd74dda..990f9a0 100755 --- a/db/provision/verify-connectivity.sh +++ b/db/provision/verify-connectivity.sh @@ -60,7 +60,7 @@ need REDIS_URL_CORE && check "core redis PING" redis-cli -u "$REDIS_URL_CORE" PI echo "== Contabo (S3) ==" if command -v deno >/dev/null 2>&1; then - if (cd "$ROOT/api" && REQUIRE_S3="${REQUIRE_S3:-}" deno run --allow-net --allow-env --allow-read --allow-write scripts/verify-storage.ts); then + if (cd "$ROOT/api" && REQUIRE_S3="${REQUIRE_S3:-}" deno run --allow-net --allow-env --allow-read --allow-write --allow-sys scripts/verify-storage.ts); then : else fail=1