Pre-computed merge for change #1

This commit is contained in:
Cursor Origin 1970-01-01 00:00:00 +00:00
commit 1c79bfa58a
94 changed files with 5757 additions and 3024 deletions

View file

@ -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 <noreply@tudominio>
# =============================================================================
# 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=
# =============================================================================
# Object storage S3-compatible (Cloudflare R2) -- expedientes/PDFs/logos.
# Sin esto, cae a disco local (solo dev).
# R2: S3_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com S3_REGION=auto
# =============================================================================
S3_ENDPOINT=
S3_BUCKET=
S3_REGION=auto
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=

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

@ -0,0 +1,123 @@
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: 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
- 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 }}

View file

@ -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"]

17
Dockerfile.migrate Normal file
View file

@ -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"]

11
Dockerfile.provision Normal file
View file

@ -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"]

View file

@ -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<string, unknown>): 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<AuthUser | null> {
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<SessionPayload>(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<string, unknown> | 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<AuthUser | null> {
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<AuthUse
const pass = password.trim();
if (!user || !pass) return null;
const db = await getDb();
const appRow = db.prepare(
`SELECT ${USER_SELECT}, u.password_hash
FROM users u LEFT JOIN companies c ON c.id = u.company_id
WHERE u.username = ?`,
).get(user) as (Record<string, unknown> & { 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): Promise<AuthUse
}
const pdb = await getPlatformDb();
const plat = pdb.prepare(
const plat = await pdb.prepare(
`SELECT id, username, display_name, password_hash FROM platform_users
WHERE username = ? AND status = 'activo'`,
).get(user) as
@ -196,15 +209,35 @@ export async function login(username: string, password: string): Promise<AuthUse
return null;
}
/** company_id de iam.users es una referencia lógica a core.companies(id)
* (sin FK -- esquemas aislados). Esta función vive en auth.ts para no
* crear un import cruzado iam<->core; 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
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" };
@ -212,6 +245,13 @@ export async function changePassword(
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);
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 {};
});
}

View file

@ -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<ParsedBudget, "format"> {
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<ParsedBudget, "format"> {
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<ParsedBudget, "format"> {
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(
): Promise<number> {
await 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);
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<void> {
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<string, number>();
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(
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);
} 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);
}
itemSort++;
}
}
export function replaceBudgetFromItems(database: Database, projectId: number, parsed: ParsedBudgetItem[]) {
export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise<void> {
const groups: ParsedGroup[] = [];
const seen = new Set<string>();
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<BudgetImportReport> {
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,

57
api/cache.ts Normal file
View file

@ -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<T>(
redis: Awaited<ReturnType<typeof getCoreRedis>>,
key: string,
ttlSeconds: number,
compute: () => Promise<T>,
): Promise<T> {
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<T>(key: string, ttlSeconds: number, compute: () => Promise<T>): Promise<T> {
const redis = await getCoreRedis();
return await cached(redis, key, ttlSeconds, compute);
}
export async function cacheIam<T>(key: string, ttlSeconds: number, compute: () => Promise<T>): Promise<T> {
const redis = await getIamRedis();
return await cached(redis, key, ttlSeconds, compute);
}
export async function invalidateCore(key: string): Promise<void> {
await (await getCoreRedis()).del(key).catch(() => {});
}

View file

@ -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<Company[]> {
// 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<Company | undefined> {
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<Company | undefined> {
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<Company | undefined> {
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<string> {
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<Company | undefined> {
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,33 +194,34 @@ 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(
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,
@ -242,16 +247,26 @@ export function createSubcompany(
profile.representante_legal,
profile.giro,
);
return { company: companyById(database, lastId(database)) };
} 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,7 +315,8 @@ export function updateCompany(
status = input.status as Company["status"];
}
database.prepare(
try {
await database.prepare(
`UPDATE companies SET
name = ?, code = ?, status = ?,
registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?,
@ -327,8 +343,17 @@ export function updateCompany(
profile.giro,
id,
);
if (code !== current.code) {
database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, 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";
}

View file

@ -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,
};

View file

@ -25,7 +25,7 @@ export async function hashPassword(password: string): Promise<string> {
["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<string> {
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<boolean> {
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<T>(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<string> {
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 };

339
api/db.ts
View file

@ -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<Database> {
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<void> {
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<T>(
tenantId: number | null,
fn: (db: Db) => Promise<T>,
): Promise<T> {
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<Db> {
return new PgDb(getCoreOwnerPool());
}
export async function closeCoreDb(): Promise<void> {
await corePool?.end({ timeout: 5 });
await coreOwnerPool?.end({ timeout: 5 });
corePool = null;
coreOwnerPool = null;
}
export async function lastInsertId(database: Db): Promise<number> {
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<string> {
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<WorkerImssContext> {
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<void> {
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<string> {
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 });
}

View file

@ -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",

View file

@ -14,10 +14,13 @@
"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": {
@ -79,6 +82,206 @@
}
},
"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 +294,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 +395,9 @@
"color-convert"
]
},
"bowser@2.14.1": {
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="
},
"camelcase@5.3.1": {
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
},
@ -127,6 +416,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 +454,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 +499,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 +517,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 +560,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 +599,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 +663,6 @@
},
"workspace": {
"dependencies": [
"jsr:@db/sqlite@0.12",
"jsr:@hono/hono@4",
"npm:pdf-lib@1.17.1",
"npm:qrcode@1.5.4",

View file

@ -6,14 +6,14 @@ async function docsKey(): Promise<CryptoKey> {
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<U
const key = await docsKey();
const iv = fromHex(ivHex);
return new Uint8Array(
await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher),
await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv as BufferSource }, key, cipher as BufferSource),
);
}

View file

@ -52,11 +52,17 @@ export function freshnessRequired(ctx: WorkerImssContext): boolean {
return alta < rehire;
}
/**
* `today` es un string ISO (YYYY-MM-DD) en la zona horaria del tenant
* (Fase 4d) -- ya no `new Date()` crudo (hora del servidor/UTC). El
* caller (api/db.ts#checklistItemsFor) lo resuelve con
* tenantTimezone()/todayInTimezone() antes de llegar aquí.
*/
export function evaluateDocumentValidity(
policy: DocTypePolicy,
doc: CurrentDocDates | null | undefined,
ctx: WorkerImssContext,
today = new Date(),
today: string = new Date().toISOString().slice(0, 10),
): {
present: boolean;
valid: boolean;
@ -89,7 +95,8 @@ export function evaluateDocumentValidity(
};
}
const exp = parseDay(expiresAt);
if (exp && exp < new Date(today.toISOString().slice(0, 10) + "T12:00:00")) {
const todayDate = parseDay(today);
if (exp && todayDate && exp < todayDate) {
return {
present: true,
valid: false,
@ -120,7 +127,8 @@ export function evaluateDocumentValidity(
if (needFresh && issuedAt) {
const issued = parseDay(issuedAt);
const days = policy.freshness_days ?? 90;
if (issued && daysBetween(issued, today) > days) {
const todayForFreshness = parseDay(today) ?? new Date();
if (issued && daysBetween(issued, todayForFreshness) > days) {
return {
present: true,
valid: false,

View file

@ -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);

View file

@ -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<Uint8Array | null> {
}
}
function upsertWorker(
db: Database,
async function upsertWorker(
db: Db,
input: ReturnType<typeof normalizeWorker> & { 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<ImportReport> {
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++;

72
api/iam_db.ts Normal file
View file

@ -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<void> {
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<string, unknown> | 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<T>(
tenantId: number | null,
fn: (db: PgDb) => Promise<T>,
): Promise<T> {
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<T>(fn: (db: PgDb) => Promise<T>): Promise<T> {
return await fn(new PgDb(getAuthPool()));
}
export async function closeIamDb(): Promise<void> {
await appPool?.end({ timeout: 5 });
await authPool?.end({ timeout: 5 });
appPool = null;
authPool = null;
}

View file

@ -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<boolean> {
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<void> {
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<void> {
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;
}

View file

@ -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<boolean> {
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 {

File diff suppressed because it is too large Load diff

View file

@ -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",
};
}

File diff suppressed because it is too large Load diff

View file

@ -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<number, number>;
}>();
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<string, unknown>[];
@ -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 });
});
}

View file

@ -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<Fixture> {
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<number> {
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<number> {
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(
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 = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
const draft = await 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 };
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,13 +150,18 @@ 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 {
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;
@ -194,53 +169,74 @@ Deno.test("armar jueves prellena jue-vie-sáb y congela requerida", () => {
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);
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", () => {
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 });
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 = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
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", () => {
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 };
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 = db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number };
const week = await 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 };
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", () => {
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");
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;
@ -251,73 +247,84 @@ Deno.test("tablero: recuperación de préstamos no es sobrante", () => {
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 });
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í", () => {
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;
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 = 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);
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", () => {
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,
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,
});
assembleWeek(db, weekId, "2026-08-27");
let week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
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 = 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 {
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);
patchDestajoCut(db, weekId, cut.id, { qty_extra: 10 });
week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
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);
payWeek(db, weekId);
assertEquals(jobRemainder(db, job.id), 140);
const nextId = ensureWeek(db, 1, addDays("2026-08-24", 7));
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 = getWeekBundle(db, 1, "2026-08-31");
const bundle = await getWeekBundle(db, TENANT_ID, "2026-08-31");
assertEquals((bundle.destajo_jobs as { remainder: number }[])[0].remainder, 140);
});
});

View file

@ -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<Uint8Array | null> {
const doc = db.prepare(
export async function loadCurrentPhoto(db: Db, workerId: number): Promise<Uint8Array | null> {
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<Uint8Array> {
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<typeof degrees> };
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<string> {
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[] {

138
api/pg.ts Normal file
View file

@ -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<string, unknown>;
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<PgRow | undefined> {
const rows = await this.sql.unsafe(toPositional(this.text), params as never[]);
return rows[0] as PgRow | undefined;
}
async all(...params: unknown[]): Promise<PgRow[]> {
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<void> {
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<number> {
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<Record<string, never>> = {}) {
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<number,...>,
// 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<T>(
sql: postgres.Sql,
tenantId: number | null,
fn: (scoped: PgDb) => Promise<T>,
): Promise<T> {
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;

View file

@ -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<Database> {
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<PlatformDb> {
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<void> {
await pool?.end({ timeout: 5 });
pool = null;
}
export function listTenants(database: Database) {
return database.prepare(
export async function pingPlatformDb(): Promise<void> {
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<number> {
return await database.lastInsertId();
}

51
api/redis.ts Normal file
View file

@ -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<RedisClientType> {
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<RedisClientType> {
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<void> {
await iamClient?.quit().catch(() => {});
await coreClient?.quit().catch(() => {});
iamClient = null;
coreClient = null;
}

View file

@ -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<boolean> {
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<string> {
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<TenantDetail | null> {
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,12 +234,14 @@ 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'
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<Record<string, unknown>>).map((a) => ({
).all(tenantId) as Array<Record<string, unknown>>;
return rows.map((a) => ({
id: Number(a.id),
username: String(a.username),
display_name: String(a.display_name),
@ -244,6 +250,7 @@ export function getTenantDetail(
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,14 +305,19 @@ 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(
// 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,
@ -319,22 +341,29 @@ export async function createTenant(
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(
await withIamOwner((iam) =>
iam.prepare(
`INSERT INTO users (
username, password_hash, display_name, company_id, tenant_id, role,
username, password_hash, display_name, company_id, tenant_id, role_code,
must_change_password, email
) VALUES (?, ?, ?, ?, ?, 'tenant_admin', 1, ?)`,
).run(adminUser, hash, adminName, companyId, tenantId, contactEmail);
) 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<string, unknown> | 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<string | null> {
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";

View file

@ -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
);

26
api/scope.ts Normal file
View file

@ -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;

View file

@ -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 <platform|tenant> [--tenant-id=1] [--company-code=ARCT2608] [--username=arct2608] [--display-name=Administrador]");
Deno.exit(1);
}
Deno.exit(0);

View file

@ -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<void> {
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<string, unknown>;
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<typeof createPool>,
schema: string,
table: string,
opts: {
sqliteTable?: string;
transform?: (row: Row) => Row | null;
columns?: string[];
identityColumn?: string | null;
conflictColumns?: string[];
} = {},
): Promise<number> {
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<typeof createPool>, schema: string, table: string): Promise<void> {
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<void> {
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<void> {
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<number, string> {
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<void> {
console.log("\n=== Carga: panels_product.core ===");
const names = userNameLookup();
await corePg.begin(async (tx) => {
const t = tx as never as ReturnType<typeof createPool>;
// 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<void> {
console.log("\n=== Verificación: conteos de filas ===");
const checks: { sqlite: SqliteDatabase; table: string; pg: ReturnType<typeof createPool>; 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<void> {
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);

View file

@ -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");

View file

@ -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<string, unknown>, k: string) {
@ -68,20 +74,20 @@ async function fetchPhoto(url: string): Promise<Uint8Array | null> {
}
}
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[] = [
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) {
for (const job of jobs) {
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(wb.Sheets[job.sheet], { defval: "" });
for (const raw of rows) {
const first = cell(raw, "NOMBRE");
@ -121,15 +127,15 @@ for (const job of jobs) {
status: job.status,
};
const errors = validateWorkerFields(input);
const company = resolveCompany(db, input) ?? resolveCompany(db, { hire_type: "ARCT2608" });
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 };
const res = upsertWorker(db, n, job.status === "activo" ? project?.id ?? null : null);
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");
@ -137,12 +143,13 @@ for (const job of jobs) {
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);
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));
});

98
api/sessions.ts Normal file
View file

@ -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:<id>` (hash con TTL) y hay un índice secundario
* `iam:user_sessions:<user_id>` (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<string> {
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<SessionData | null> {
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<void> {
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<void> {
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);
}

View file

@ -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<SmtpRow | undefined> {
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<SmtpSettings & { source: "db" | "env" | "none" }> {
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<boolean> {
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<SmtpPublic> {
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) };
}

145
api/storage.ts Normal file
View file

@ -0,0 +1,145 @@
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";
/**
* 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: 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;
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<StoragePing> {
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<void> {
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({
endpoint: config.s3Endpoint,
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,
},
});
}
return client;
}
async function streamToUint8Array(body: unknown): Promise<Uint8Array> {
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<void> {
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<Uint8Array> {
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}`;
}

54
api/test_helpers.ts Normal file
View file

@ -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<T>(fn: (db: PgDb) => Promise<T>): Promise<T> {
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<void> {
await pool?.end({ timeout: 1 });
pool = null;
}

View file

@ -1,28 +1,81 @@
# 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
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
# descarga CLI + JDBC (una vez) y aplica changesets
./db/update.sh all
# o solo una
./db/update.sh app
./db/update.sh platform
# 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 --context-filter='!dev' # staging/producción: solo esquema + catálogos
# Un solo módulo
./db/update.sh core --context-filter='!dev'
./db/update.sh iam --context-filter='!dev'
./db/update.sh platform --context-filter='!dev'
```
También: `npm run db:migrate` o al arrancar la API (`runLiquibase()` en Deno).
**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.
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`.
`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).
Las herramientas viven en `db/tools/` (gitignored); `bootstrap-tools.sh` las descarga.
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.
## 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.

89
db/RUNBOOK-corte.md Normal file
View file

@ -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.

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
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
https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-tenant-id.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-must-change-password.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-document-catalog.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-payroll-week.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -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);

View file

@ -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);

View file

@ -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 '';

View file

@ -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'))
);

View file

@ -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;

View file

@ -1,4 +0,0 @@
changeLogFile=changelog-master.xml
driver=org.sqlite.JDBC
url=jdbc:sqlite:../../data/app.db
classpath=../tools/sqlite-jdbc.jar

108
db/backups/README.md Normal file
View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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).

View file

@ -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.

View file

@ -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."

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
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">
<!-- panels_product, esquema core: negocio (empresas, personal, obras,
expedientes, presupuesto, nómina, gafetes). -->
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-tenant-settings.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-seed-catalogs.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-seed-dev-data.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-rls.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -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
);

View file

@ -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()
);

View file

@ -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;

View file

@ -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');

View file

@ -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);

View file

@ -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

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
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">
<!-- panels_product, esquema iam: identidad/roles/permisos del tenant. -->
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-rls.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -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.

View file

@ -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 = '<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);

View file

@ -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

View file

@ -3,13 +3,9 @@
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
https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
<!-- panels_platform: control plane SaaS. Un solo esquema (public). -->
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
<include file="changesets/001-init.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-seed-arctec-tenant.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-tenant-license.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-tenant-status-expand.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-contact-access.sql" relativeToChangelogFile="true"/>
<include file="changesets/006-tenant-code-date.sql" relativeToChangelogFile="true"/>
<include file="changesets/007-smtp-settings.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -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));

View file

@ -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'))
);

View file

@ -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');

View file

@ -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 '';

View file

@ -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;

View file

@ -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;

View file

@ -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';

View file

@ -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);

View file

@ -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

51
db/provision/01-roles.sql Normal file
View file

@ -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';

View file

@ -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;

View file

@ -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;

View file

@ -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;

64
db/provision/05-redis-acl.sh Executable file
View file

@ -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)."

110
db/provision/README.md Normal file
View file

@ -0,0 +1,110 @@
# 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:<pw>@HOST:6379`),
`REDIS_URL_CORE` (`redis://panels_core_redis:<pw>@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).
## 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
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. 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
`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.

131
db/provision/create-accesses.sh Executable file
View file

@ -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 <<EOF
# PANELS · secretos de un solo ambiente (no mezclar prod/staging/dev)
SESSION_SECRET=${SESSION_SECRET}
DOCS_KEY=${DOCS_KEY}
DATABASE_URL_PLATFORM=postgresql://panels_platform_app:${PLATFORM_APP_PASSWORD}@${PGHOST}:${PGPORT}/panels_platform
DATABASE_URL_PLATFORM_OWNER=postgresql://panels_platform_owner:${PLATFORM_OWNER_PASSWORD}@${PGHOST}:${PGPORT}/panels_platform
DATABASE_URL_IAM=postgresql://panels_iam_app:${IAM_APP_PASSWORD}@${PGHOST}:${PGPORT}/panels_product
DATABASE_URL_IAM_OWNER=postgresql://panels_iam_owner:${IAM_OWNER_PASSWORD}@${PGHOST}:${PGPORT}/panels_product
DATABASE_URL_CORE=postgresql://panels_core_app:${CORE_APP_PASSWORD}@${PGHOST}:${PGPORT}/panels_product
DATABASE_URL_CORE_OWNER=postgresql://panels_core_owner:${CORE_OWNER_PASSWORD}@${PGHOST}:${PGPORT}/panels_product
REDIS_URL_IAM=redis://panels_iam_redis:${IAM_REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
REDIS_URL_CORE=redis://panels_core_redis:${CORE_REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
# Contabo -- rellenar endpoint/keys del Object Storage de ESTE ambiente
S3_ENDPOINT=${S3_ENDPOINT:-}
S3_BUCKET=${S3_BUCKET:-}
S3_REGION=${S3_REGION:-usc1}
S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY:-}
# Passwords sueltos (provision / compose local)
POSTGRES_SUPERUSER_PASSWORD=${PGPASSWORD:-}
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}
IAM_REDIS_PASSWORD=${IAM_REDIS_PASSWORD}
CORE_REDIS_PASSWORD=${CORE_REDIS_PASSWORD}
EOF
}
if [[ "$APPLY" == "1" ]]; then
: "${PGPASSWORD:?PGPASSWORD del superusuario es obligatorio con --apply}"
SUPERUSER_URL="postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}:${PGPORT}/postgres"
echo "== Aplicando roles/bases/esquemas en ${PGHOST}:${PGPORT} =="
psql "$SUPERUSER_URL" \
-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 "$HERE/01-roles.sql"
psql "$SUPERUSER_URL" -f "$HERE/02-databases.sql"
psql "postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}:${PGPORT}/panels_platform" -f "$HERE/03-platform-database.sql"
psql "postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}:${PGPORT}/panels_product" -f "$HERE/04-product-database.sql"
if [[ -n "${REDIS_ADMIN_URL:-}" ]]; then
echo "== Aplicando ACLs Redis =="
REDIS_ADMIN_URL="$REDIS_ADMIN_URL" \
IAM_REDIS_PASSWORD="$IAM_REDIS_PASSWORD" \
CORE_REDIS_PASSWORD="$CORE_REDIS_PASSWORD" \
"$HERE/05-redis-acl.sh"
else
echo "SKIP - REDIS_ADMIN_URL no definido; no se crearon usuarios ACL"
fi
fi
ENV_TEXT="$(block)"
echo "$ENV_TEXT"
if [[ -n "$OUT" ]]; then
umask 077
printf '%s\n' "$ENV_TEXT" > "$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

62
db/provision/dev-local.sh Executable file
View file

@ -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" <<EOF
# Generado por db/provision/dev-local.sh — solo para desarrollo local. No commitear.
DATABASE_URL_PLATFORM=postgresql://panels_platform_app:${PLATFORM_APP_PW}@${PGHOST}:${PGPORT}/panels_platform
DATABASE_URL_PLATFORM_OWNER=postgresql://panels_platform_owner:${PLATFORM_OWNER_PW}@${PGHOST}:${PGPORT}/panels_platform
DATABASE_URL_IAM=postgresql://panels_iam_app:${IAM_APP_PW}@${PGHOST}:${PGPORT}/panels_product
DATABASE_URL_IAM_OWNER=postgresql://panels_iam_owner:${IAM_OWNER_PW}@${PGHOST}:${PGPORT}/panels_product
DATABASE_URL_CORE=postgresql://panels_core_app:${CORE_APP_PW}@${PGHOST}:${PGPORT}/panels_product
DATABASE_URL_CORE_OWNER=postgresql://panels_core_owner:${CORE_OWNER_PW}@${PGHOST}:${PGPORT}/panels_product
REDIS_URL_IAM=redis://panels_iam_redis:${IAM_REDIS_PW}@${REDIS_HOST}:${REDIS_PORT}
REDIS_URL_CORE=redis://panels_core_redis:${CORE_REDIS_PW}@${REDIS_HOST}:${REDIS_PORT}
EOF
echo ""
echo "Listo. Credenciales de desarrollo local escritas en $(cd "$(dirname "$ENV_FILE")" && pwd)/$(basename "$ENV_FILE")"

View file

@ -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."

View file

@ -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

View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
# PANELS · Fase 0 · verifica que el aislamiento entre módulos realmente
# se cumple (no solo que las credenciales existan). Pensado para correr
# después de 01-04 (Postgres) y 05 (Redis), en cualquier ambiente.
#
# Uso: source el .env con DATABASE_URL_*/REDIS_URL_* y correr este script.
set -uo pipefail
cd "$(dirname "$0")"
fail=0
check() {
local desc="$1"; local expect_fail="$2"; shift 2
if "$@" >/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

View file

@ -1,38 +1,85 @@
#!/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
# ./db/update.sh all --context-filter='!dev' -- updateSQL # dry-run (CI)
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
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() {
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 $LB_COMMAND: $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" \
"$LB_COMMAND" "${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

80
docker-compose.local.yml Normal file
View file

@ -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:

View file

@ -1,17 +1,36 @@
# Coolify / local — 3 services, SQLite en volumen panel-data
# 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:
migrate:
build:
context: .
dockerfile: Dockerfile.migrate
restart: "no"
environment:
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:
context: .
dockerfile: Dockerfile.api
restart: unless-stopped
depends_on:
migrate:
condition: service_completed_successfully
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:-}
@ -20,6 +39,19 @@ services:
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-}
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:-auto}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
volumes:
- panel-data:/app/data
expose:
@ -35,7 +67,7 @@ services:
interval: 30s
timeout: 5s
retries: 5
start_period: 45s
start_period: 15s
web-panel:
build:

View file

@ -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 | Cloudflare R2 (S3-compatible) para expedientes/PDFs/logos cifrados |
| 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.
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,24 @@ 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_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`) |
| `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 +63,60 @@ 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` | Cloudflare R2. Endpoint `https://<ACCOUNT_ID>.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).
## 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, 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.<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).
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. 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
## Local (todo en docker-compose, incluyendo Postgres/Redis propios)
```bash
cp .env.example .env # rellenar obligatorias
docker compose up --build
cp .env.example .env
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build
```
Sin `SESSION_SECRET` / `DOCS_KEY` / `SEED_PASSWORD` / `PANEL_LOGIN_URL`, compose **no arranca**.
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).
## 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)

View file

@ -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"
}
}

View file

@ -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<string>([
...(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();