mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 16:13:17 +00:00
Compare commits
No commits in common. "b596bb70cdcdefbf75d049c8f7546a34e92ef657" and "97c66282bfad2b0cf4d2eb3601926a8ef1e819e2" have entirely different histories.
b596bb70cd
...
97c66282bf
94 changed files with 3013 additions and 5907 deletions
56
.env.example
56
.env.example
|
|
@ -1,16 +1,17 @@
|
|||
# =============================================================================
|
||||
# Copiar a `.env` (gitignored) para local / Coolify.
|
||||
# NUNCA subas `.env` ni `data/*` al repositorio.
|
||||
#
|
||||
# Generar cada password/secreto con: openssl rand -hex 24
|
||||
# NUNCA subas `.env` ni `data/*.db` al repositorio.
|
||||
# =============================================================================
|
||||
|
||||
# --- 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
|
||||
|
|
@ -24,54 +25,9 @@ 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 panels_platform)
|
||||
# SMTP (opcional; también se puede configurar en SaaS → /smtp y queda en platform.db)
|
||||
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
123
.github/workflows/ci.yml
vendored
|
|
@ -1,123 +0,0 @@
|
|||
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 --allow-sys
|
||||
|
||||
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 }}
|
||||
|
|
@ -1,14 +1,26 @@
|
|||
# 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.
|
||||
# API PANELS — Deno + SQLite FFI + Liquibase/JRE (Debian slim, no Alpine)
|
||||
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 mkdir -p /app/data && chown -R deno:deno /app
|
||||
RUN chmod +x ./db/bootstrap-tools.sh \
|
||||
&& ./db/bootstrap-tools.sh \
|
||||
&& mkdir -p /app/data \
|
||||
&& chown -R deno:deno /app
|
||||
|
||||
USER deno
|
||||
WORKDIR /app/api
|
||||
|
|
@ -17,8 +29,7 @@ RUN deno cache main.ts
|
|||
ENV PORT=8000
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --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)"]
|
||||
|
||||
# --allow-sys: AWS SDK v3 (R2) lee osRelease; sin esto Deno lanza NotCapable.
|
||||
CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "--allow-sys", "main.ts"]
|
||||
CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "--allow-ffi", "--allow-run", "main.ts"]
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
# 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"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# 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"]
|
||||
166
api/auth.ts
166
api/auth.ts
|
|
@ -1,10 +1,9 @@
|
|||
import type { Context, Next } from "hono";
|
||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||
import { withIamTenant, findUserByUsernameAnyTenant } from "./iam_db.ts";
|
||||
import { getDb } from "./db.ts";
|
||||
import { getPlatformDb } from "./platform_db.ts";
|
||||
import { config } from "./config.ts";
|
||||
import { createSession, getSession, revokeSession, revokeAllSessionsForUser } from "./sessions.ts";
|
||||
import { hashPassword, verifyPassword } from "./crypto.ts";
|
||||
import { b64urlJson, b64urlJsonParse, hmacSign, hmacVerify, hashPassword, verifyPassword } from "./crypto.ts";
|
||||
|
||||
export type AuthRealm = "app" | "platform";
|
||||
export type AuthRole = "platform_admin" | "tenant_admin" | "user";
|
||||
|
|
@ -23,33 +22,34 @@ export type AuthUser = {
|
|||
must_change_password: boolean;
|
||||
};
|
||||
|
||||
const COOKIE = "po_session";
|
||||
const TTL_SECONDS = 60 * 60 * 24 * 7;
|
||||
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`;
|
||||
|
||||
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, {
|
||||
type SessionPayload = { uid: number; exp: number; realm?: AuthRealm };
|
||||
|
||||
const COOKIE = "po_session";
|
||||
const TTL = 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}`, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "Lax",
|
||||
secure: config.cookieSecure,
|
||||
maxAge: TTL_SECONDS,
|
||||
maxAge: TTL,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearSession(c: Context) {
|
||||
const id = getCookie(c, COOKIE);
|
||||
if (id) await revokeSession(id);
|
||||
export function clearSession(c: Context) {
|
||||
deleteCookie(c, COOKIE, { path: "/" });
|
||||
}
|
||||
|
||||
function asAppUser(row: Record<string, unknown>): AuthUser {
|
||||
const role = (row.role_code as string) || "user";
|
||||
const role = (row.role 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 id = getCookie(c, COOKIE);
|
||||
if (!id) return null;
|
||||
const session = await getSession(id);
|
||||
if (!session) return 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";
|
||||
|
||||
if (session.realm === "platform") {
|
||||
if (realm === "platform") {
|
||||
const pdb = await getPlatformDb();
|
||||
const row = await pdb.prepare(
|
||||
const row = pdb.prepare(
|
||||
`SELECT id, username, display_name FROM platform_users
|
||||
WHERE id = ? AND status = 'activo'`,
|
||||
).get(session.userId) as { id: number; username: string; display_name: string } | undefined;
|
||||
).get(payload.uid) as { id: number; username: string; display_name: string } | undefined;
|
||||
return row ? asPlatformUser(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 });
|
||||
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;
|
||||
}
|
||||
|
||||
function apiKeyOk(c: Context): boolean {
|
||||
|
|
@ -124,15 +124,6 @@ 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",
|
||||
|
|
@ -141,7 +132,7 @@ export async function requireAuth(c: Context, next: Next) {
|
|||
company_code: null,
|
||||
company_name: null,
|
||||
company_kind: null,
|
||||
tenant_id: tenantId,
|
||||
tenant_id: null,
|
||||
role: "tenant_admin",
|
||||
realm: "app",
|
||||
must_change_password: false,
|
||||
|
|
@ -175,21 +166,17 @@ export async function login(username: string, password: string): Promise<AuthUse
|
|||
const pass = password.trim();
|
||||
if (!user || !pass) return null;
|
||||
|
||||
// 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 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");
|
||||
const blocked = await tenantAccessBlocked(pdb, authUser.tenant_id);
|
||||
const pdb = await getPlatformDb();
|
||||
const blocked = tenantAccessBlocked(pdb, authUser.tenant_id);
|
||||
if (blocked) {
|
||||
throw Object.assign(new Error(blocked), { code: "TENANT_BLOCKED" });
|
||||
}
|
||||
|
|
@ -197,7 +184,7 @@ export async function login(username: string, password: string): Promise<AuthUse
|
|||
}
|
||||
|
||||
const pdb = await getPlatformDb();
|
||||
const plat = await pdb.prepare(
|
||||
const plat = pdb.prepare(
|
||||
`SELECT id, username, display_name, password_hash FROM platform_users
|
||||
WHERE username = ? AND status = 'activo'`,
|
||||
).get(user) as
|
||||
|
|
@ -209,49 +196,22 @@ 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" };
|
||||
return await withIamTenant(tenantId, async (db) => {
|
||||
const row = await db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId) as
|
||||
| { password_hash: string }
|
||||
| undefined;
|
||||
if (!row) return { error: "Usuario no encontrado" };
|
||||
if (!await verifyPassword(currentPassword, row.password_hash)) {
|
||||
return { error: "Contraseña actual incorrecta" };
|
||||
}
|
||||
const hash = await hashPassword(next);
|
||||
await db.prepare(
|
||||
"UPDATE users SET password_hash = ?, must_change_password = false WHERE id = ?",
|
||||
).run(hash, userId);
|
||||
// Cambiar password revoca TODAS las demás sesiones activas de este
|
||||
// usuario -- antes (cookie HMAC stateless) esto era imposible; ahora
|
||||
// sí, porque el estado real vive en Redis (ver sessions.ts).
|
||||
await revokeAllSessionsForUser(userId, "app");
|
||||
return {};
|
||||
});
|
||||
const db = await getDb();
|
||||
const row = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId) as
|
||||
| { password_hash: string }
|
||||
| undefined;
|
||||
if (!row) return { error: "Usuario no encontrado" };
|
||||
if (!await verifyPassword(currentPassword, row.password_hash)) {
|
||||
return { error: "Contraseña actual incorrecta" };
|
||||
}
|
||||
const hash = await hashPassword(next);
|
||||
db.prepare("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?").run(hash, userId);
|
||||
return {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import * as XLSX from "xlsx";
|
||||
import type { Db } from "./db.ts";
|
||||
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);
|
||||
}
|
||||
|
||||
export const BUDGET_IVA = 0.16;
|
||||
|
||||
|
|
@ -262,7 +266,7 @@ function looksOpus(rows: unknown[][], headerAt: number, map: ColMap) {
|
|||
return false;
|
||||
}
|
||||
|
||||
function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<ParsedBudget, "format"> {
|
||||
function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget {
|
||||
const groups: ParsedGroup[] = [];
|
||||
const items: ParsedBudgetItem[] = [];
|
||||
let skipped = 0;
|
||||
|
|
@ -322,7 +326,7 @@ function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<Pa
|
|||
return { groups, items, skipped };
|
||||
}
|
||||
|
||||
function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<ParsedBudget, "format"> {
|
||||
function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget {
|
||||
const groups: ParsedGroup[] = [];
|
||||
const items: ParsedBudgetItem[] = [];
|
||||
let skipped = 0;
|
||||
|
|
@ -424,7 +428,7 @@ function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<P
|
|||
return { groups, items, skipped };
|
||||
}
|
||||
|
||||
function parseFlatRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<ParsedBudget, "format"> {
|
||||
function parseFlatRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget {
|
||||
const groups: ParsedGroup[] = [];
|
||||
const items: ParsedBudgetItem[] = [];
|
||||
let skipped = 0;
|
||||
|
|
@ -641,12 +645,14 @@ function compareWbs(a: string, b: string) {
|
|||
return a.localeCompare(b, undefined, { numeric: true });
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
FROM budget_chapters c WHERE c.project_id = ? ORDER BY c.sort_order, c.id`,
|
||||
).all(projectId) as ChapterRow[];
|
||||
const rawItems = await database.prepare(
|
||||
const rawItems = 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
|
||||
|
|
@ -744,47 +750,64 @@ export async function listBudget(database: Db, projectId: number) {
|
|||
};
|
||||
}
|
||||
|
||||
async function insertChapter(
|
||||
database: Db,
|
||||
function insertChapter(
|
||||
database: Database,
|
||||
projectId: number,
|
||||
parentId: number | null,
|
||||
code: string,
|
||||
name: string,
|
||||
wbs: string,
|
||||
sort: number,
|
||||
): 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);
|
||||
return await database.lastInsertId();
|
||||
) {
|
||||
const cols = (database.prepare("PRAGMA table_info(budget_chapters)").all() as { name: string }[]).map((c) => c.name);
|
||||
if (cols.includes("wbs")) {
|
||||
database.prepare(
|
||||
"INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run(projectId, parentId, code, name, wbs, sort);
|
||||
} else {
|
||||
database.prepare(
|
||||
"INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)",
|
||||
).run(projectId, parentId, code, name, sort);
|
||||
}
|
||||
return lastId(database);
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
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 = await insertChapter(database, projectId, parentId, group.code, group.name, group.wbs, sort);
|
||||
const id = 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;
|
||||
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);
|
||||
if (hasWbs) {
|
||||
database.prepare(
|
||||
`INSERT INTO budget_items
|
||||
(project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs);
|
||||
} else {
|
||||
database.prepare(
|
||||
`INSERT INTO budget_items
|
||||
(project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort);
|
||||
}
|
||||
itemSort++;
|
||||
}
|
||||
}
|
||||
|
||||
export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise<void> {
|
||||
export function replaceBudgetFromItems(database: Database, projectId: number, parsed: ParsedBudgetItem[]) {
|
||||
const groups: ParsedGroup[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of parsed) {
|
||||
|
|
@ -799,7 +822,7 @@ export async function replaceBudgetFromItems(database: Db, projectId: number, pa
|
|||
name: item.chapter || "General",
|
||||
});
|
||||
}
|
||||
await replaceBudgetFromParsed(database, projectId, { format: "flat", groups, items: parsed, skipped: 0 });
|
||||
replaceBudgetFromParsed(database, projectId, { format: "flat", groups, items: parsed, skipped: 0 });
|
||||
}
|
||||
|
||||
export function lineAmount(quantity: number, unitPrice: number) {
|
||||
|
|
@ -902,7 +925,7 @@ export function previewBudgetExcel(bytes: Uint8Array): BudgetPreview {
|
|||
};
|
||||
}
|
||||
|
||||
export async function importBudgetExcel(database: Db, projectId: number, bytes: Uint8Array): Promise<BudgetImportReport> {
|
||||
export function importBudgetExcel(database: Database, projectId: number, bytes: Uint8Array): BudgetImportReport {
|
||||
const read = readBudgetSheet(bytes);
|
||||
if ("error" in read) {
|
||||
return { replaced: false, chapters: 0, inserted: 0, skipped: 0, errors: [{ row: 0, messages: [read.error] }] };
|
||||
|
|
@ -917,7 +940,7 @@ export async function importBudgetExcel(database: Db, projectId: number, bytes:
|
|||
errors: [{ row: 0, messages: ["No se encontraron partidas. Use la plantilla o un Excel con CLAVE, DESCRIPCION, UNIDAD, CANTIDAD y PRECIO."] }],
|
||||
};
|
||||
}
|
||||
await replaceBudgetFromParsed(database, projectId, parsed);
|
||||
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 = {
|
||||
|
|
@ -927,7 +950,7 @@ export async function importBudgetExcel(database: Db, projectId: number, bytes:
|
|||
item_count: parsed.items.length,
|
||||
};
|
||||
// El monto de contrato del proyecto refleja el subtotal del presupuesto importado.
|
||||
await database.prepare("UPDATE projects SET contract_amount = ? WHERE id = ?").run(subtotal, projectId);
|
||||
database.prepare("UPDATE projects SET contract_amount = ? WHERE id = ?").run(subtotal, projectId);
|
||||
return {
|
||||
replaced: true,
|
||||
chapters: parsed.groups.length,
|
||||
|
|
|
|||
57
api/cache.ts
57
api/cache.ts
|
|
@ -1,57 +0,0 @@
|
|||
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(() => {});
|
||||
}
|
||||
219
api/companies.ts
219
api/companies.ts
|
|
@ -1,4 +1,8 @@
|
|||
import type { Db } from "./db.ts";
|
||||
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);
|
||||
}
|
||||
|
||||
export type Company = {
|
||||
id: number;
|
||||
|
|
@ -60,41 +64,36 @@ function normRfc(value: unknown): string {
|
|||
return trimText(value).toUpperCase().replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
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).
|
||||
export function listCompanies(database: Database, tenantId?: number | null): Company[] {
|
||||
const where = tenantId != null ? "WHERE c.tenant_id = ?" : "";
|
||||
const params = tenantId != null ? [tenantId] : [];
|
||||
return await database.prepare(
|
||||
return 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, LOWER(c.name)`,
|
||||
ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, c.name COLLATE NOCASE`,
|
||||
).all(...params) as Company[];
|
||||
}
|
||||
|
||||
export async function companyById(database: Db, id: number): Promise<Company | undefined> {
|
||||
return await database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as
|
||||
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
|
||||
| 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> {
|
||||
export function principalCompany(database: Database, tenantId?: number | null): Company | undefined {
|
||||
if (tenantId != null) {
|
||||
return await database.prepare(
|
||||
return database.prepare(
|
||||
"SELECT * FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1",
|
||||
).get(tenantId) as Company | undefined;
|
||||
}
|
||||
return await database.prepare(
|
||||
return database.prepare(
|
||||
"SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1",
|
||||
).get() as Company | undefined;
|
||||
}
|
||||
|
|
@ -113,26 +112,26 @@ export function companyCodeFromName(name: string): string {
|
|||
return slug || "EMP";
|
||||
}
|
||||
|
||||
export async function nextCompanyCode(database: Db, name: string): Promise<string> {
|
||||
export function nextCompanyCode(database: Database, name: string): string {
|
||||
const base = companyCodeFromName(name);
|
||||
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}'`,
|
||||
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]*'`,
|
||||
).get() as { n: number };
|
||||
return `EMP-${String(row.n).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
export async function resolveCompany(
|
||||
database: Db,
|
||||
export function resolveCompany(
|
||||
database: Database,
|
||||
body: { company_id?: number | null; hire_type?: string | null },
|
||||
): Promise<Company | undefined> {
|
||||
): Company | undefined {
|
||||
if (body.company_id) {
|
||||
const byId = await companyById(database, Number(body.company_id));
|
||||
const byId = companyById(database, Number(body.company_id));
|
||||
if (byId) return byId;
|
||||
}
|
||||
const code = normCompanyCode(body.hire_type);
|
||||
if (code) return await companyByCode(database, code);
|
||||
if (code) return companyByCode(database, code);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -143,10 +142,7 @@ 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)";
|
||||
|
|
@ -194,79 +190,68 @@ function profileFromInput(input: CompanyProfileInput, fallbackName = "") {
|
|||
return normalizeCompanyProfile(input, fallbackName);
|
||||
}
|
||||
|
||||
export async function createSubcompany(
|
||||
database: Db,
|
||||
export function createSubcompany(
|
||||
database: Database,
|
||||
input: CompanyProfileInput,
|
||||
tenantId?: number | null,
|
||||
): Promise<{ company?: Company; error?: string }> {
|
||||
): { 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 = await principalCompany(database, tenantId);
|
||||
const principal = principalCompany(database, tenantId);
|
||||
if (!principal) return { error: "No hay empresa principal" };
|
||||
const parentId = input.parent_id ? Number(input.parent_id) : principal.id;
|
||||
const parent = await companyById(database, parentId);
|
||||
const parent = 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 = await nextCompanyCode(database, profile.name);
|
||||
if (!code) code = nextCompanyCode(database, profile.name);
|
||||
const codeErr = validateCompanyCode(code);
|
||||
if (codeErr) return { error: codeErr };
|
||||
if (await companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" };
|
||||
if (companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" };
|
||||
|
||||
const tid = tenantId ?? principal.tenant_id ?? null;
|
||||
try {
|
||||
await database.prepare(
|
||||
`INSERT INTO companies (
|
||||
code, name, parent_id, kind, status, tenant_id,
|
||||
registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo,
|
||||
domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro
|
||||
) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
code,
|
||||
profile.name,
|
||||
parent.id,
|
||||
tid,
|
||||
profile.registro_patronal,
|
||||
profile.razon_social,
|
||||
profile.nombre_comercial,
|
||||
profile.rfc,
|
||||
profile.regimen_fiscal,
|
||||
profile.clase_riesgo,
|
||||
profile.domicilio_fiscal,
|
||||
profile.codigo_postal,
|
||||
profile.ciudad,
|
||||
profile.estado,
|
||||
profile.telefono,
|
||||
profile.email,
|
||||
profile.representante_legal,
|
||||
profile.giro,
|
||||
);
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código" };
|
||||
throw e;
|
||||
}
|
||||
return { company: await companyById(database, await database.lastInsertId()) };
|
||||
database.prepare(
|
||||
`INSERT INTO companies (
|
||||
code, name, parent_id, kind, status, tenant_id,
|
||||
registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo,
|
||||
domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro
|
||||
) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
code,
|
||||
profile.name,
|
||||
parent.id,
|
||||
tid,
|
||||
profile.registro_patronal,
|
||||
profile.razon_social,
|
||||
profile.nombre_comercial,
|
||||
profile.rfc,
|
||||
profile.regimen_fiscal,
|
||||
profile.clase_riesgo,
|
||||
profile.domicilio_fiscal,
|
||||
profile.codigo_postal,
|
||||
profile.ciudad,
|
||||
profile.estado,
|
||||
profile.telefono,
|
||||
profile.email,
|
||||
profile.representante_legal,
|
||||
profile.giro,
|
||||
);
|
||||
return { company: companyById(database, lastId(database)) };
|
||||
}
|
||||
|
||||
export async function updateCompany(
|
||||
database: Db,
|
||||
export function updateCompany(
|
||||
database: Database,
|
||||
id: number,
|
||||
input: CompanyProfileInput,
|
||||
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 };
|
||||
}
|
||||
): { company?: Company; error?: string; status?: 400 | 404 } {
|
||||
const current = companyById(database, id);
|
||||
if (!current) return { error: "Empresa no encontrada", status: 404 };
|
||||
|
||||
const profileErr = validateProfile(input);
|
||||
if (profileErr) return { error: profileErr, status: 400 };
|
||||
|
|
@ -300,7 +285,7 @@ export async function updateCompany(
|
|||
code = normCompanyCode(input.code);
|
||||
const codeErr = validateCompanyCode(code);
|
||||
if (codeErr) return { error: codeErr, status: 400 };
|
||||
const clash = await companyByCode(database, code);
|
||||
const clash = companyByCode(database, code);
|
||||
if (clash && clash.id !== id) return { error: "Ya existe una empresa con ese código", status: 400 };
|
||||
}
|
||||
|
||||
|
|
@ -315,45 +300,35 @@ export async function updateCompany(
|
|||
status = input.status as Company["status"];
|
||||
}
|
||||
|
||||
try {
|
||||
await database.prepare(
|
||||
`UPDATE companies SET
|
||||
name = ?, code = ?, status = ?,
|
||||
registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?,
|
||||
domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?,
|
||||
representante_legal = ?, giro = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
profile.name,
|
||||
code,
|
||||
status,
|
||||
profile.registro_patronal,
|
||||
profile.razon_social,
|
||||
profile.nombre_comercial,
|
||||
profile.rfc,
|
||||
profile.regimen_fiscal,
|
||||
profile.clase_riesgo,
|
||||
profile.domicilio_fiscal,
|
||||
profile.codigo_postal,
|
||||
profile.ciudad,
|
||||
profile.estado,
|
||||
profile.telefono,
|
||||
profile.email,
|
||||
profile.representante_legal,
|
||||
profile.giro,
|
||||
id,
|
||||
);
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código", status: 400 };
|
||||
throw e;
|
||||
}
|
||||
database.prepare(
|
||||
`UPDATE companies SET
|
||||
name = ?, code = ?, status = ?,
|
||||
registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?,
|
||||
domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?,
|
||||
representante_legal = ?, giro = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
profile.name,
|
||||
code,
|
||||
status,
|
||||
profile.registro_patronal,
|
||||
profile.razon_social,
|
||||
profile.nombre_comercial,
|
||||
profile.rfc,
|
||||
profile.regimen_fiscal,
|
||||
profile.clase_riesgo,
|
||||
profile.domicilio_fiscal,
|
||||
profile.codigo_postal,
|
||||
profile.ciudad,
|
||||
profile.estado,
|
||||
profile.telefono,
|
||||
profile.email,
|
||||
profile.representante_legal,
|
||||
profile.giro,
|
||||
id,
|
||||
);
|
||||
if (code !== current.code) {
|
||||
await database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id);
|
||||
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";
|
||||
return { company: companyById(database, id) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,39 +4,20 @@ import { fileURLToPath } from "node:url";
|
|||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
export const ROOT = join(here, "..");
|
||||
export const DATA_DIR = join(ROOT, "data");
|
||||
// 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 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");
|
||||
|
||||
export const config = {
|
||||
port: Number(Deno.env.get("PORT") ?? "8000"),
|
||||
sessionSecret: requiredOrDevFallback("SESSION_SECRET", "dev-session-secret-change-me"),
|
||||
sessionSecret: Deno.env.get("SESSION_SECRET") ?? "dev-session-secret-change-me",
|
||||
apiKey: Deno.env.get("API_KEY") ?? "",
|
||||
docsKeyHex: requiredOrDevFallback(
|
||||
"DOCS_KEY",
|
||||
docsKeyHex: Deno.env.get("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=",
|
||||
|
|
@ -51,38 +32,4 @@ 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") ?? "").trim().replace(/\/$/, ""),
|
||||
s3Bucket: (Deno.env.get("S3_BUCKET") ?? "").trim(),
|
||||
s3Region: (Deno.env.get("S3_REGION") ?? "auto").trim() || "auto",
|
||||
s3AccessKeyId: (Deno.env.get("S3_ACCESS_KEY_ID") ?? "").trim(),
|
||||
s3SecretAccessKey: Deno.env.get("S3_SECRET_ACCESS_KEY") ?? "",
|
||||
|
||||
isDev,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export async function hashPassword(password: string): Promise<string> {
|
|||
["deriveBits"],
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: "PBKDF2", salt: salt as BufferSource, iterations: 120_000, hash: "SHA-256" },
|
||||
{ name: "PBKDF2", salt, 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: salt as BufferSource, iterations: 120_000, hash: "SHA-256" },
|
||||
{ name: "PBKDF2", salt, iterations: 120_000, hash: "SHA-256" },
|
||||
key,
|
||||
256,
|
||||
),
|
||||
|
|
@ -66,8 +66,44 @@ 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 as BufferSource));
|
||||
return toHex(await crypto.subtle.digest("SHA-256", data));
|
||||
}
|
||||
|
||||
export { toHex, fromHex, encoder, decoder };
|
||||
|
|
|
|||
339
api/db.ts
339
api/db.ts
|
|
@ -1,64 +1,93 @@
|
|||
import postgres, { createPool, PgDb, withTenant } from "./pg.ts";
|
||||
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 { config } from "./config.ts";
|
||||
import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.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";
|
||||
|
||||
export type Db = PgDb;
|
||||
export type Db = Database;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
let db: Database | null = null;
|
||||
|
||||
let corePool: postgres.Sql | null = null;
|
||||
let coreOwnerPool: postgres.Sql | null = null;
|
||||
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;
|
||||
}
|
||||
|
||||
function getCorePool(): postgres.Sql {
|
||||
if (!corePool) {
|
||||
corePool = createPool(config.databaseUrlCore, { max: 20 });
|
||||
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,
|
||||
);
|
||||
}
|
||||
return corePool;
|
||||
}
|
||||
|
||||
function getCoreOwnerPool(): postgres.Sql {
|
||||
if (!coreOwnerPool) {
|
||||
coreOwnerPool = createPool(config.databaseUrlCoreOwner || config.databaseUrlCore, { max: 3 });
|
||||
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);
|
||||
}
|
||||
return coreOwnerPool;
|
||||
}
|
||||
|
||||
export async function pingCoreDb(): Promise<void> {
|
||||
await getCorePool()`SELECT 1`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 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")}`;
|
||||
}
|
||||
|
||||
export const PROJECT_STATUSES = ["activo", "pausado", "concluido", "cancelado"] as const;
|
||||
|
|
@ -75,16 +104,8 @@ export function isProjectStatus(value: string): value is ProjectStatus {
|
|||
return (PROJECT_STATUSES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
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
|
||||
export function projectById(database: Database, id: number) {
|
||||
return database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as
|
||||
| { id: number; code: string; name: string; status: string }
|
||||
| undefined;
|
||||
}
|
||||
|
|
@ -101,17 +122,103 @@ export function projectMustBe(
|
|||
return null;
|
||||
}
|
||||
|
||||
export async function workerImssContext(database: Db, workerId: number): Promise<WorkerImssContext> {
|
||||
const w = await database.prepare(
|
||||
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(
|
||||
"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 = await database.prepare(
|
||||
const altaDoc = database.prepare(
|
||||
`SELECT imss_alta_at, uploaded_at FROM documents
|
||||
WHERE worker_id = ? AND type_code = 'alta_imss' AND is_current = true
|
||||
WHERE worker_id = ? AND type_code = 'alta_imss' AND is_current = 1
|
||||
LIMIT 1`,
|
||||
).get(workerId) as { imss_alta_at: string | null; uploaded_at: string } | undefined;
|
||||
return {
|
||||
|
|
@ -121,30 +228,25 @@ export async function workerImssContext(database: Db, workerId: number): Promise
|
|||
};
|
||||
}
|
||||
|
||||
/** 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(
|
||||
/** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor. */
|
||||
export function checklistItemsFor(database: Database, workerId: number) {
|
||||
const types = 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: boolean;
|
||||
required: number;
|
||||
validity_mode: string;
|
||||
freshness_days: number | null;
|
||||
requires_issued_at: boolean;
|
||||
requires_expires_at: boolean;
|
||||
requires_issued_at: number;
|
||||
requires_expires_at: number;
|
||||
category: string;
|
||||
}[];
|
||||
const currentDocs = await database.prepare(
|
||||
const currentDocs = database.prepare(
|
||||
`SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at
|
||||
FROM documents WHERE worker_id = ? AND is_current = true`,
|
||||
FROM documents WHERE worker_id = ? AND is_current = 1`,
|
||||
).all(workerId) as {
|
||||
type_code: string;
|
||||
issued_at: string | null;
|
||||
|
|
@ -153,7 +255,7 @@ export async function checklistItemsFor(database: Db, workerId: number, tenantId
|
|||
imss_alta_at: string | null;
|
||||
}[];
|
||||
const byType = new Map(currentDocs.map((d) => [d.type_code, d]));
|
||||
const ctx = await workerImssContext(database, workerId);
|
||||
const ctx = workerImssContext(database, workerId);
|
||||
|
||||
return {
|
||||
ctx,
|
||||
|
|
@ -168,7 +270,7 @@ export async function checklistItemsFor(database: Db, workerId: number, tenantId
|
|||
requires_issued_at: !!t.requires_issued_at,
|
||||
requires_expires_at: !!t.requires_expires_at,
|
||||
};
|
||||
const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx, today);
|
||||
const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx);
|
||||
return {
|
||||
code: t.code,
|
||||
label: t.label,
|
||||
|
|
@ -188,11 +290,11 @@ export async function checklistItemsFor(database: Db, workerId: number, tenantId
|
|||
};
|
||||
}
|
||||
|
||||
export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) {
|
||||
const { ctx, freshness_required: needFresh, items } = await checklistItemsFor(database, workerId, tenantId);
|
||||
export function imssFlagsFor(database: Database, workerId: number) {
|
||||
const { ctx, freshness_required: needFresh, items } = checklistItemsFor(database, workerId);
|
||||
const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
|
||||
const inProject = !!await database.prepare(
|
||||
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1",
|
||||
const inProject = !!database.prepare(
|
||||
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1",
|
||||
).get(workerId);
|
||||
const hasImss = ctx.imss_status === "alta";
|
||||
return {
|
||||
|
|
@ -204,45 +306,62 @@ export async function imssFlagsFor(database: Db, workerId: number, tenantId: num
|
|||
};
|
||||
}
|
||||
|
||||
export async function checklistFor(database: Db, workerId: number, tenantId: number | null = null) {
|
||||
return (await checklistItemsFor(database, workerId, tenantId)).items;
|
||||
export function checklistFor(database: Database, workerId: number) {
|
||||
return checklistItemsFor(database, workerId).items;
|
||||
}
|
||||
|
||||
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
|
||||
export function refreshPipeline(database: Database, workerId: number) {
|
||||
const w = database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as
|
||||
| { status: string }
|
||||
| undefined;
|
||||
if (!w) return;
|
||||
if (w.status === "baja") {
|
||||
await database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId);
|
||||
database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId);
|
||||
return;
|
||||
}
|
||||
const items = await checklistFor(database, workerId, tenantId);
|
||||
const items = checklistFor(database, workerId);
|
||||
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 = await database.prepare(
|
||||
const printed = 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 = await database.prepare(
|
||||
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1",
|
||||
const assigned = database.prepare(
|
||||
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1",
|
||||
).get(workerId);
|
||||
if (pipeline !== "incompleto" && assigned) {
|
||||
pipeline = printed ? "activo" : "listo_gafete";
|
||||
}
|
||||
await database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId);
|
||||
database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId);
|
||||
}
|
||||
|
||||
export async function projectChecklistFor(database: Db, projectId: number) {
|
||||
const types = await database.prepare(
|
||||
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(
|
||||
"SELECT code, label, required, category FROM project_document_types",
|
||||
).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() 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(projectId) as { type_code: string }[];
|
||||
const have = new Set(current.map((c) => c.type_code));
|
||||
return types.map((t) => ({
|
||||
|
|
@ -254,12 +373,12 @@ export async function projectChecklistFor(database: Db, projectId: number) {
|
|||
}));
|
||||
}
|
||||
|
||||
export async function companyChecklistFor(database: Db, companyId: number) {
|
||||
const types = await database.prepare(
|
||||
export function companyChecklistFor(database: Database, companyId: number) {
|
||||
const types = database.prepare(
|
||||
"SELECT code, label, required, category FROM company_document_types",
|
||||
).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() 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(companyId) as { type_code: string }[];
|
||||
const have = new Set(current.map((c) => c.type_code));
|
||||
return types.map((t) => ({
|
||||
|
|
@ -270,21 +389,3 @@ export async function companyChecklistFor(database: Db, 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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
"name": "panel-obra-api",
|
||||
"exports": "./main.ts",
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-net --allow-read --allow-write --allow-env --allow-sys --env-file=../.env main.ts",
|
||||
"start": "deno run --allow-net --allow-read --allow-write --allow-env --allow-sys main.ts",
|
||||
"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",
|
||||
"migrate": "cd .. && ./db/update.sh all",
|
||||
"check": "deno check main.ts",
|
||||
"test": "deno test --allow-net --allow-read --allow-write --allow-env --allow-sys"
|
||||
"test": "deno test --allow-read --allow-write --allow-env --allow-ffi --allow-run"
|
||||
},
|
||||
"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",
|
||||
|
|
|
|||
318
api/deno.lock
318
api/deno.lock
|
|
@ -14,13 +14,10 @@
|
|||
"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": {
|
||||
|
|
@ -82,206 +79,6 @@
|
|||
}
|
||||
},
|
||||
"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": [
|
||||
|
|
@ -294,89 +91,6 @@
|
|||
"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": [
|
||||
|
|
@ -395,9 +109,6 @@
|
|||
"color-convert"
|
||||
]
|
||||
},
|
||||
"bowser@2.14.1": {
|
||||
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="
|
||||
},
|
||||
"camelcase@5.3.1": {
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
|
||||
},
|
||||
|
|
@ -416,9 +127,6 @@
|
|||
"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=="
|
||||
},
|
||||
|
|
@ -454,9 +162,6 @@
|
|||
"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=="
|
||||
},
|
||||
|
|
@ -499,15 +204,12 @@
|
|||
"@pdf-lib/standard-fonts",
|
||||
"@pdf-lib/upng",
|
||||
"pako",
|
||||
"tslib@1.14.1"
|
||||
"tslib"
|
||||
]
|
||||
},
|
||||
"pngjs@5.0.0": {
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="
|
||||
},
|
||||
"postgres@3.4.9": {
|
||||
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="
|
||||
},
|
||||
"qrcode@1.5.4": {
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"dependencies": [
|
||||
|
|
@ -517,17 +219,6 @@
|
|||
],
|
||||
"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=="
|
||||
},
|
||||
|
|
@ -560,9 +251,6 @@
|
|||
"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=="
|
||||
},
|
||||
|
|
@ -599,9 +287,6 @@
|
|||
"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": [
|
||||
|
|
@ -663,6 +348,7 @@
|
|||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@db/sqlite@0.12",
|
||||
"jsr:@hono/hono@4",
|
||||
"npm:pdf-lib@1.17.1",
|
||||
"npm:qrcode@1.5.4",
|
||||
|
|
|
|||
|
|
@ -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 as BufferSource, "AES-GCM", false, ["encrypt", "decrypt"]);
|
||||
return await crypto.subtle.importKey("raw", raw, "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 as BufferSource),
|
||||
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plain),
|
||||
);
|
||||
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: iv as BufferSource }, key, cipher as BufferSource),
|
||||
await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,17 +52,11 @@ 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: string = new Date().toISOString().slice(0, 10),
|
||||
today = new Date(),
|
||||
): {
|
||||
present: boolean;
|
||||
valid: boolean;
|
||||
|
|
@ -95,8 +89,7 @@ export function evaluateDocumentValidity(
|
|||
};
|
||||
}
|
||||
const exp = parseDay(expiresAt);
|
||||
const todayDate = parseDay(today);
|
||||
if (exp && todayDate && exp < todayDate) {
|
||||
if (exp && exp < new Date(today.toISOString().slice(0, 10) + "T12:00:00")) {
|
||||
return {
|
||||
present: true,
|
||||
valid: false,
|
||||
|
|
@ -127,8 +120,7 @@ export function evaluateDocumentValidity(
|
|||
if (needFresh && issuedAt) {
|
||||
const issued = parseDay(issuedAt);
|
||||
const days = policy.freshness_days ?? 90;
|
||||
const todayForFreshness = parseDay(today) ?? new Date();
|
||||
if (issued && daysBetween(issued, todayForFreshness) > days) {
|
||||
if (issued && daysBetween(issued, today) > days) {
|
||||
return {
|
||||
present: true,
|
||||
valid: false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"2026-08-20",
|
||||
new Date("2026-08-20T12:00:00"),
|
||||
);
|
||||
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,
|
||||
"2026-08-20",
|
||||
new Date("2026-08-20T12:00:00"),
|
||||
);
|
||||
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,
|
||||
"2026-08-20",
|
||||
new Date("2026-08-20T12:00:00"),
|
||||
);
|
||||
assertEquals(result.validity_status, "expired");
|
||||
assertEquals(result.valid, false);
|
||||
|
|
|
|||
113
api/excel.ts
113
api/excel.ts
|
|
@ -1,11 +1,12 @@
|
|||
import * as XLSX from "xlsx";
|
||||
import type { Db } from "./db.ts";
|
||||
import type { Database } from "@db/sqlite";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { encryptBytes } from "./docs_crypto.ts";
|
||||
import { sha256Hex } from "./crypto.ts";
|
||||
import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts";
|
||||
import { refreshPipeline, lastInsertId } from "./db.ts";
|
||||
import { refreshPipeline, workerDir, lastInsertId, projectDir, companyDir } from "./db.ts";
|
||||
import { resolveCompany } from "./companies.ts";
|
||||
import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts";
|
||||
|
||||
export const IMPORT_COLUMNS = [
|
||||
"NOMBRE",
|
||||
|
|
@ -189,8 +190,8 @@ export function buildImportTemplate(companies: { code: string; name: string }[]
|
|||
return out instanceof Uint8Array ? out : new Uint8Array(out);
|
||||
}
|
||||
|
||||
async function findExisting(db: Db, curp: string, rfc: string, nss: string) {
|
||||
return await db.prepare(
|
||||
function findExisting(db: Database, curp: string, rfc: string, nss: string) {
|
||||
return 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
|
||||
|
|
@ -199,7 +200,7 @@ async function findExisting(db: Db, curp: string, rfc: string, nss: string) {
|
|||
}
|
||||
|
||||
export async function storeDocument(
|
||||
db: Db,
|
||||
db: Database,
|
||||
workerId: number,
|
||||
type: string,
|
||||
filename: string,
|
||||
|
|
@ -214,14 +215,16 @@ export async function storeDocument(
|
|||
imss_baja_at?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
const allowed = await db.prepare("SELECT code FROM document_types WHERE code = ?").get(type);
|
||||
const allowed = 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`;
|
||||
await putObject(workerDocKey(workerId, storage), cipher);
|
||||
await db.prepare("UPDATE documents SET is_current = false WHERE worker_id = ? AND type_code = ?").run(
|
||||
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(
|
||||
workerId,
|
||||
type,
|
||||
);
|
||||
|
|
@ -233,11 +236,11 @@ export async function storeDocument(
|
|||
const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null);
|
||||
const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt;
|
||||
|
||||
await db.prepare(
|
||||
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_id, uploaded_by_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, ?, ?, ?, ?, '')`,
|
||||
parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
workerId,
|
||||
type,
|
||||
|
|
@ -256,16 +259,16 @@ export async function storeDocument(
|
|||
|
||||
if (type === "alta_imss") {
|
||||
const companyId = imssCompanyId ||
|
||||
(await db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined)
|
||||
(db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined)
|
||||
?.company_id ||
|
||||
null;
|
||||
const company = companyId
|
||||
? await db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as
|
||||
? 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");
|
||||
await db.prepare(
|
||||
db.prepare(
|
||||
`UPDATE workers SET
|
||||
imss_status = 'alta',
|
||||
imss_company_id = ?,
|
||||
|
|
@ -273,29 +276,29 @@ export async function storeDocument(
|
|||
imss_baja_at = NULL,
|
||||
company_id = ?,
|
||||
hire_type = ?,
|
||||
updated_at = now()
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(company.id, imssAltaAt, company.id, company.code, workerId);
|
||||
}
|
||||
|
||||
if (type === "baja_imss") {
|
||||
await db.prepare(
|
||||
db.prepare(
|
||||
`UPDATE workers SET
|
||||
imss_status = 'baja_imss',
|
||||
imss_baja_at = ?,
|
||||
imss_company_id = NULL,
|
||||
company_id = NULL,
|
||||
hire_type = '',
|
||||
updated_at = now()
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(imssBajaAt, workerId);
|
||||
}
|
||||
|
||||
await refreshPipeline(db, workerId);
|
||||
refreshPipeline(db, workerId);
|
||||
}
|
||||
|
||||
export async function storeProjectDocument(
|
||||
db: Db,
|
||||
db: Database,
|
||||
projectId: number,
|
||||
type: string,
|
||||
filename: string,
|
||||
|
|
@ -303,25 +306,27 @@ export async function storeProjectDocument(
|
|||
bytes: Uint8Array,
|
||||
userId: number | null,
|
||||
) {
|
||||
const allowed = await db.prepare("SELECT code FROM project_document_types WHERE code = ?").get(type);
|
||||
const allowed = 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`;
|
||||
await putObject(projectDocKey(projectId, storage), cipher);
|
||||
await db.prepare("UPDATE project_documents SET is_current = false WHERE project_id = ? AND type_code = ?").run(
|
||||
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(
|
||||
projectId,
|
||||
type,
|
||||
);
|
||||
await db.prepare(
|
||||
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_id, uploaded_by_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`,
|
||||
(project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?)`,
|
||||
).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
|
||||
}
|
||||
|
||||
export async function storeCompanyDocument(
|
||||
db: Db,
|
||||
db: Database,
|
||||
companyId: number,
|
||||
type: string,
|
||||
filename: string,
|
||||
|
|
@ -329,20 +334,22 @@ export async function storeCompanyDocument(
|
|||
bytes: Uint8Array,
|
||||
userId: number | null,
|
||||
) {
|
||||
const allowed = await db.prepare("SELECT code FROM company_document_types WHERE code = ?").get(type);
|
||||
const allowed = 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`;
|
||||
await putObject(companyDocKey(companyId, storage), cipher);
|
||||
await db.prepare("UPDATE company_documents SET is_current = false WHERE company_id = ? AND type_code = ?").run(
|
||||
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(
|
||||
companyId,
|
||||
type,
|
||||
);
|
||||
await db.prepare(
|
||||
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_id, uploaded_by_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`,
|
||||
(company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?)`,
|
||||
).run(companyId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
|
||||
}
|
||||
|
||||
|
|
@ -357,19 +364,19 @@ async function fetchPhoto(url: string): Promise<Uint8Array | null> {
|
|||
}
|
||||
}
|
||||
|
||||
async function upsertWorker(
|
||||
db: Db,
|
||||
function upsertWorker(
|
||||
db: Database,
|
||||
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null },
|
||||
projectId: number | null,
|
||||
) {
|
||||
const existing = await findExisting(db, input.curp, input.rfc, input.nss);
|
||||
const existing = findExisting(db, input.curp, input.rfc, input.nss);
|
||||
if (existing) {
|
||||
await db.prepare(
|
||||
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=now()
|
||||
needs_badge=?, status=?, updated_at=datetime('now')
|
||||
WHERE id=?`,
|
||||
).run(
|
||||
input.first_name,
|
||||
|
|
@ -394,12 +401,12 @@ async function upsertWorker(
|
|||
input.status,
|
||||
existing.id,
|
||||
);
|
||||
if (projectId) await assign(db, existing.id, projectId);
|
||||
await refreshPipeline(db, existing.id, input.tenant_id ?? null);
|
||||
if (projectId) assign(db, existing.id, projectId);
|
||||
refreshPipeline(db, existing.id);
|
||||
const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS";
|
||||
return { id: existing.id, action: "existed" as const, matched };
|
||||
}
|
||||
await db.prepare(
|
||||
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)
|
||||
|
|
@ -426,19 +433,19 @@ async function upsertWorker(
|
|||
input.status,
|
||||
input.tenant_id ?? null,
|
||||
);
|
||||
const id = await lastInsertId(db);
|
||||
if (projectId) await assign(db, id, projectId);
|
||||
await refreshPipeline(db, id, input.tenant_id ?? null);
|
||||
const id = lastInsertId(db);
|
||||
if (projectId) assign(db, id, projectId);
|
||||
refreshPipeline(db, id);
|
||||
return { id, action: "inserted" as const, matched: null as string | null };
|
||||
}
|
||||
|
||||
async function assign(db: Db, workerId: number, projectId: number) {
|
||||
await db.prepare(
|
||||
function assign(db: Database, workerId: number, projectId: number) {
|
||||
db.prepare(
|
||||
`INSERT INTO assignments (worker_id, project_id, active, start_date)
|
||||
VALUES (?, ?, true, current_date)
|
||||
VALUES (?, ?, 1, date('now'))
|
||||
ON CONFLICT(worker_id, project_id) DO UPDATE SET
|
||||
active=true,
|
||||
start_date=CASE WHEN assignments.active=false THEN excluded.start_date ELSE assignments.start_date END,
|
||||
active=1,
|
||||
start_date=CASE WHEN assignments.active=0 THEN excluded.start_date ELSE assignments.start_date END,
|
||||
end_date=NULL`,
|
||||
).run(workerId, projectId);
|
||||
}
|
||||
|
|
@ -453,14 +460,14 @@ export type ImportReport = {
|
|||
};
|
||||
|
||||
export async function importExcel(
|
||||
db: Db,
|
||||
db: Database,
|
||||
bytes: Uint8Array,
|
||||
projectId: number | null,
|
||||
userId: number | null,
|
||||
): Promise<ImportReport> {
|
||||
const wb = XLSX.read(bytes, { type: "array" });
|
||||
const risks = new Set(
|
||||
(await db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code),
|
||||
(db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code),
|
||||
);
|
||||
const report: ImportReport = {
|
||||
inserted: 0,
|
||||
|
|
@ -502,7 +509,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 = await resolveCompany(db, input);
|
||||
const company = 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";
|
||||
|
|
@ -539,7 +546,7 @@ export async function importExcel(
|
|||
company_id: company!.id,
|
||||
tenant_id: company!.tenant_id ?? 1,
|
||||
};
|
||||
const res = await upsertWorker(db, n, n.status === "activo" ? projectId : null);
|
||||
const res = upsertWorker(db, n, n.status === "activo" ? projectId : null);
|
||||
if (res.action === "inserted") report.inserted++;
|
||||
else {
|
||||
report.existed++;
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
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;
|
||||
}
|
||||
67
api/liquibase.ts
Normal file
67
api/liquibase.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -18,8 +18,8 @@ export type MailPayload = {
|
|||
attachments?: MailAttachment[];
|
||||
};
|
||||
|
||||
export async function smtpConfigured(platformDb: PlatformDb): Promise<boolean> {
|
||||
return await smtpConfiguredFromDb(platformDb);
|
||||
export function smtpConfigured(platformDb: PlatformDb): boolean {
|
||||
return 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 (!await smtpConfigured(platformDb)) {
|
||||
if (!smtpConfigured(platformDb)) {
|
||||
return { sent: false, error: "SMTP no configurado o deshabilitado" };
|
||||
}
|
||||
|
||||
const s = await resolveSmtp(platformDb);
|
||||
const s = resolveSmtp(platformDb);
|
||||
const from = (s.from_address ?? "").trim();
|
||||
if (!from || !/(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(from)) {
|
||||
return {
|
||||
|
|
|
|||
686
api/main.ts
686
api/main.ts
File diff suppressed because it is too large
Load diff
|
|
@ -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),
|
||||
needs_badge: body.needs_badge === false || body.needs_badge === 0 ? 0 : 1,
|
||||
status: body.status === "baja" ? "baja" : "activo",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
461
api/payroll.ts
461
api/payroll.ts
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,7 @@
|
|||
import type { Context, Hono } from "hono";
|
||||
import type { Hono } from "hono";
|
||||
import type { AuthUser } from "./auth.ts";
|
||||
import { tenantScope } from "./auth.ts";
|
||||
import { requireCoreAuth } from "./scope.ts";
|
||||
import { lastInsertId, projectById, projectMustBe, type Db } from "./db.ts";
|
||||
import { tenantScope, requireAuth } from "./auth.ts";
|
||||
import { getDb, lastInsertId, projectById, projectMustBe } from "./db.ts";
|
||||
import { generateLoanReceiptPdf } from "./pdf.ts";
|
||||
import { fullName } from "./mx.ts";
|
||||
import {
|
||||
|
|
@ -20,34 +19,38 @@ import {
|
|||
payWeek,
|
||||
patchDestajoCut,
|
||||
removeAdminLine,
|
||||
resolveToday,
|
||||
saveSettings,
|
||||
setAttendance,
|
||||
tenantKey,
|
||||
todayIso,
|
||||
updateAdminLine,
|
||||
weekContaining,
|
||||
weekCsv,
|
||||
} from "./payroll.ts";
|
||||
|
||||
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
|
||||
type App = Hono<{ Variables: { user: AuthUser } }>;
|
||||
|
||||
function tid(c: { get: (k: "user") => AuthUser }): number {
|
||||
return tenantKey(tenantScope(c.get("user")));
|
||||
}
|
||||
|
||||
function pdfBody(c: Context, bytes: Uint8Array, filename: string) {
|
||||
async function pdfBody(
|
||||
c: { header: (k: string, v: string) => void; body: (data: ArrayBuffer) => unknown },
|
||||
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", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/attendance", requireAuth, async (c) => {
|
||||
const projectId = Number(c.req.query("project_id"));
|
||||
const from = c.req.query("from") ?? "";
|
||||
const to = c.req.query("to") ?? "";
|
||||
const db = c.get("db");
|
||||
const rows = await db.prepare(
|
||||
const db = await getDb();
|
||||
const rows = 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 ?
|
||||
|
|
@ -56,72 +59,72 @@ export function registerPayrollRoutes(app: App) {
|
|||
return c.json({ attendance: rows });
|
||||
});
|
||||
|
||||
app.put("/v1/attendance", ...requireCoreAuth, async (c) => {
|
||||
app.put("/v1/attendance", requireAuth, async (c) => {
|
||||
const body = await c.req.json<{
|
||||
project_id: number;
|
||||
worker_id: number;
|
||||
work_date: string;
|
||||
present: boolean;
|
||||
}>();
|
||||
const db = c.get("db");
|
||||
const db = await getDb();
|
||||
const blocked = projectMustBe(
|
||||
await projectById(db, body.project_id),
|
||||
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 = await setAttendance(db, tid(c), body);
|
||||
const result = 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", ...requireCoreAuth, async (c) => {
|
||||
const db = c.get("db");
|
||||
app.get("/v1/payroll/settings", requireAuth, async (c) => {
|
||||
const db = await getDb();
|
||||
const tenantId = tid(c);
|
||||
return c.json({ settings: await getSettings(db, tenantId), units: await listUnits(db, tenantId) });
|
||||
return c.json({ settings: getSettings(db, tenantId), units: listUnits(db, tenantId) });
|
||||
});
|
||||
|
||||
app.patch("/v1/payroll/settings", ...requireCoreAuth, async (c) => {
|
||||
const db = c.get("db");
|
||||
app.patch("/v1/payroll/settings", requireAuth, async (c) => {
|
||||
const db = await getDb();
|
||||
const body = await c.req.json<{
|
||||
loan_commission_enabled?: number | boolean;
|
||||
loan_commission_pct?: number;
|
||||
loan_small_max?: number;
|
||||
}>();
|
||||
const settings = await saveSettings(db, tid(c), {
|
||||
const settings = saveSettings(db, tid(c), {
|
||||
loan_commission_enabled: body.loan_commission_enabled === undefined
|
||||
? undefined
|
||||
: Boolean(body.loan_commission_enabled),
|
||||
: body.loan_commission_enabled ? 1 : 0,
|
||||
loan_commission_pct: body.loan_commission_pct,
|
||||
loan_small_max: body.loan_small_max,
|
||||
});
|
||||
return c.json({ settings });
|
||||
});
|
||||
|
||||
app.post("/v1/destajo/units", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/destajo/units", requireAuth, async (c) => {
|
||||
const body = await c.req.json<{ code: string; label?: string }>();
|
||||
const db = c.get("db");
|
||||
const db = await getDb();
|
||||
try {
|
||||
const id = await addUnit(db, tid(c), body.code, body.label ?? body.code);
|
||||
const id = 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", ...requireCoreAuth, async (c) => {
|
||||
const db = c.get("db");
|
||||
app.get("/v1/loans", requireAuth, async (c) => {
|
||||
const db = await getDb();
|
||||
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 ? await db.prepare(sql).all(Number(workerId)) : await db.prepare(sql).all();
|
||||
const loans = workerId ? db.prepare(sql).all(Number(workerId)) : db.prepare(sql).all();
|
||||
return c.json({ loans });
|
||||
});
|
||||
|
||||
app.post("/v1/loans", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/loans", requireAuth, async (c) => {
|
||||
const body = await c.req.json<{
|
||||
worker_id: number;
|
||||
amount?: number;
|
||||
|
|
@ -133,9 +136,9 @@ export function registerPayrollRoutes(app: App) {
|
|||
plan?: "single" | "installments";
|
||||
installments_n?: number;
|
||||
}>();
|
||||
const db = c.get("db");
|
||||
const db = await getDb();
|
||||
try {
|
||||
const created = await createLoan(db, tid(c), {
|
||||
const created = createLoan(db, tid(c), {
|
||||
worker_id: body.worker_id,
|
||||
delivered: Number(body.delivered ?? body.amount ?? 0),
|
||||
commission_enabled: body.commission_enabled,
|
||||
|
|
@ -151,10 +154,10 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.get("/v1/loans/:id/recibo", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/loans/:id/recibo", requireAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const loan = await db.prepare(
|
||||
const db = await getDb();
|
||||
const loan = 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 {
|
||||
|
|
@ -199,10 +202,10 @@ export function registerPayrollRoutes(app: App) {
|
|||
return pdfBody(c, bytes, `prestamo-aceptacion-${id}.pdf`);
|
||||
});
|
||||
|
||||
app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/loan-payments/:id/recibo", requireAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const pay = await db.prepare(
|
||||
const db = await getDb();
|
||||
const pay = 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
|
||||
|
|
@ -235,24 +238,23 @@ export function registerPayrollRoutes(app: App) {
|
|||
return pdfBody(c, bytes, `prestamo-pago-${id}.pdf`);
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/weeks", ...requireCoreAuth, async (c) => {
|
||||
const db = c.get("db");
|
||||
app.get("/v1/payroll/weeks", requireAuth, async (c) => {
|
||||
const db = await getDb();
|
||||
const tenantId = tid(c);
|
||||
const start = c.req.query("week_start") || weekContaining(await resolveToday(db, tenantId)).weekStart;
|
||||
const bundle = await getWeekBundle(db, tenantId, start);
|
||||
const start = c.req.query("week_start") || weekContaining(todayIso()).weekStart;
|
||||
const bundle = getWeekBundle(db, tenantId, start);
|
||||
return c.json(bundle);
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/weeks/open", ...requireCoreAuth, async (c) => {
|
||||
const db = c.get("db");
|
||||
return c.json({ weeks: await listOpenWeeks(db, tid(c)) });
|
||||
app.get("/v1/payroll/weeks/open", requireAuth, async (c) => {
|
||||
const db = await getDb();
|
||||
return c.json({ weeks: listOpenWeeks(db, tid(c)) });
|
||||
});
|
||||
|
||||
app.post("/v1/payroll/weeks/:id/assemble", ...requireCoreAuth, async (c) => {
|
||||
const db = c.get("db");
|
||||
app.post("/v1/payroll/weeks/:id/assemble", requireAuth, async (c) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
const nowIso = await resolveToday(db, tid(c));
|
||||
await assembleWeek(db, Number(c.req.param("id")), nowIso);
|
||||
assembleWeek(db, Number(c.req.param("id")));
|
||||
return c.json({ ok: true });
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
|
|
@ -260,11 +262,10 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.post("/v1/payroll/weeks/:id/pay", ...requireCoreAuth, async (c) => {
|
||||
const db = c.get("db");
|
||||
app.post("/v1/payroll/weeks/:id/pay", requireAuth, async (c) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
const nowIso = await resolveToday(db, tid(c));
|
||||
await payWeek(db, Number(c.req.param("id")), nowIso);
|
||||
payWeek(db, Number(c.req.param("id")));
|
||||
return c.json({ ok: true });
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
|
|
@ -272,20 +273,20 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/weeks/:id/csv", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/payroll/weeks/:id/csv", requireAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const csv = await weekCsv(db, id);
|
||||
const db = await getDb();
|
||||
const csv = 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", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/payroll/weeks/:id/jornal", requireAuth, async (c) => {
|
||||
const body = await c.req.json<{ project_id: number; worker_id: number }>();
|
||||
const db = c.get("db");
|
||||
const db = await getDb();
|
||||
try {
|
||||
await addJornalWorker(db, Number(c.req.param("id")), body.project_id, body.worker_id);
|
||||
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 };
|
||||
|
|
@ -293,11 +294,11 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.post("/v1/payroll/weeks/:id/admin", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/payroll/weeks/:id/admin", requireAuth, async (c) => {
|
||||
const body = await c.req.json<{ worker_id: number; amount: number; project_id?: number | null }>();
|
||||
const db = c.get("db");
|
||||
const db = await getDb();
|
||||
try {
|
||||
await addAdminLine(db, Number(c.req.param("id")), body);
|
||||
addAdminLine(db, Number(c.req.param("id")), body);
|
||||
return c.json({ ok: true });
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
|
|
@ -305,11 +306,11 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.patch("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/payroll/weeks/:id/admin/:lineId", requireAuth, async (c) => {
|
||||
const body = await c.req.json<{ amount: number }>();
|
||||
const db = c.get("db");
|
||||
const db = await getDb();
|
||||
try {
|
||||
await updateAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId")), body.amount);
|
||||
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 };
|
||||
|
|
@ -317,10 +318,10 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.delete("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => {
|
||||
const db = c.get("db");
|
||||
app.delete("/v1/payroll/weeks/:id/admin/:lineId", requireAuth, async (c) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
await removeAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId")));
|
||||
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 };
|
||||
|
|
@ -328,7 +329,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.post("/v1/destajo/jobs", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/destajo/jobs", requireAuth, async (c) => {
|
||||
const body = await c.req.json<{
|
||||
week_id: number;
|
||||
project_id: number;
|
||||
|
|
@ -340,9 +341,9 @@ export function registerPayrollRoutes(app: App) {
|
|||
qty_planned?: number;
|
||||
qty_extra?: number;
|
||||
}>();
|
||||
const db = c.get("db");
|
||||
const db = await getDb();
|
||||
try {
|
||||
const created = await createDestajoJob(db, tid(c), body.week_id, body);
|
||||
const created = createDestajoJob(db, tid(c), body.week_id, body);
|
||||
return c.json(created, 201);
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
|
|
@ -350,16 +351,16 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.patch("/v1/destajo/cuts/:id", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/destajo/cuts/:id", requireAuth, async (c) => {
|
||||
const body = await c.req.json<{
|
||||
week_id: number;
|
||||
qty_planned?: number;
|
||||
qty_actual?: number;
|
||||
qty_extra?: number;
|
||||
}>();
|
||||
const db = c.get("db");
|
||||
const db = await getDb();
|
||||
try {
|
||||
await patchDestajoCut(db, body.week_id, Number(c.req.param("id")), body);
|
||||
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 };
|
||||
|
|
@ -367,27 +368,27 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.post("/v1/payroll/periods", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/payroll/periods", requireAuth, async (c) => {
|
||||
const body = await c.req.json<{
|
||||
project_id: number;
|
||||
week_start: string;
|
||||
week_end: string;
|
||||
extra_discounts?: Record<number, number>;
|
||||
}>();
|
||||
const db = c.get("db");
|
||||
const db = await getDb();
|
||||
const blocked = projectMustBe(
|
||||
await projectById(db, body.project_id),
|
||||
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);
|
||||
await db.prepare(
|
||||
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 = await lastInsertId(db);
|
||||
const workers = await db.prepare(
|
||||
const periodId = lastInsertId(db);
|
||||
const workers = 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=true
|
||||
JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=1
|
||||
WHERE w.status='activo'`,
|
||||
).all(body.project_id) as { id: number; daily_wage: number }[];
|
||||
const ins = db.prepare(
|
||||
|
|
@ -395,47 +396,47 @@ export function registerPayrollRoutes(app: App) {
|
|||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const w of workers) {
|
||||
const att = await db.prepare(
|
||||
const att = db.prepare(
|
||||
`SELECT COUNT(*) AS n FROM attendance
|
||||
WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`,
|
||||
WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=1`,
|
||||
).get(w.id, body.project_id, body.week_start, body.week_end) as { n: number };
|
||||
const days = Number(att.n);
|
||||
const days = att.n;
|
||||
const gross = days * w.daily_wage;
|
||||
const extra = Number(body.extra_discounts?.[w.id] ?? 0);
|
||||
const net = gross - extra;
|
||||
await ins.run(periodId, w.id, days, w.daily_wage, gross, extra, 0, net);
|
||||
ins.run(periodId, w.id, days, w.daily_wage, gross, extra, 0, net);
|
||||
}
|
||||
return c.json({ id: periodId });
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => {
|
||||
const db = c.get("db");
|
||||
app.get("/v1/payroll/periods", requireAuth, async (c) => {
|
||||
const db = await getDb();
|
||||
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 ? await db.prepare(sql).all(Number(projectId)) : await db.prepare(sql).all();
|
||||
const periods = projectId ? db.prepare(sql).all(Number(projectId)) : db.prepare(sql).all();
|
||||
return c.json({ periods });
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/payroll/periods/:id", requireAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const period = await db.prepare(
|
||||
const db = await getDb();
|
||||
const period = 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 = await db.prepare(
|
||||
const lines = 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", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/payroll/periods/:id/csv", requireAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const lines = await db.prepare(
|
||||
const db = await getDb();
|
||||
const lines = 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>[];
|
||||
|
|
@ -449,11 +450,11 @@ export function registerPayrollRoutes(app: App) {
|
|||
return c.body(csv);
|
||||
});
|
||||
|
||||
app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/payroll/periods/:id", requireAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const { status } = await c.req.json<{ status: string }>();
|
||||
const db = c.get("db");
|
||||
await db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id);
|
||||
const db = await getDb();
|
||||
db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { assertEquals, assertExists, assertRejects } from "jsr:@std/assert@1";
|
||||
import type { PgDb } from "./pg.ts";
|
||||
import { withTestDb } from "./test_helpers.ts";
|
||||
import { assertEquals, assertExists, assertThrows } from "jsr:@std/assert@1";
|
||||
import { Database } from "@db/sqlite";
|
||||
import {
|
||||
addDays,
|
||||
assembleWeek,
|
||||
|
|
@ -23,58 +22,92 @@ import {
|
|||
weekContaining,
|
||||
} from "./payroll.ts";
|
||||
|
||||
// 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();
|
||||
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;
|
||||
}
|
||||
|
||||
Deno.test("semana lun-sáb y corte destajo jue-jue", () => {
|
||||
|
|
@ -86,28 +119,25 @@ 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", async () => {
|
||||
Deno.test("armar jueves solo a partir del jueves de esa semana", () => {
|
||||
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);
|
||||
|
||||
await withTestDb(async (db) => {
|
||||
await seedFixture(db);
|
||||
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||
await assertRejects(
|
||||
() => assembleWeek(db, weekId, "2026-08-26"),
|
||||
Error,
|
||||
"jueves",
|
||||
);
|
||||
const draft = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
|
||||
assertEquals(draft.status, "draft");
|
||||
await assembleWeek(db, weekId, "2026-08-28");
|
||||
const after = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
|
||||
assertEquals(after.status, "assembled");
|
||||
});
|
||||
const db = mem();
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
assertThrows(
|
||||
() => assembleWeek(db, weekId, "2026-08-26"),
|
||||
Error,
|
||||
"jueves",
|
||||
);
|
||||
const draft = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
|
||||
assertEquals(draft.status, "draft");
|
||||
assembleWeek(db, weekId, "2026-08-28");
|
||||
const after = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
|
||||
assertEquals(after.status, "assembled");
|
||||
});
|
||||
|
||||
Deno.test("comisión suma al saldo", () => {
|
||||
|
|
@ -150,181 +180,144 @@ 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", async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const { projectId, workerId } = await seedFixture(db);
|
||||
await db.prepare(
|
||||
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-24', true)",
|
||||
).run(workerId, projectId);
|
||||
await db.prepare(
|
||||
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)",
|
||||
).run(workerId, projectId);
|
||||
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||
await assembleWeek(db, weekId, "2026-08-27");
|
||||
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
status: string;
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
};
|
||||
assertEquals(week.status, "assembled");
|
||||
assertEquals(week.required_net, 2500);
|
||||
assertEquals(week.payable_net, 2500);
|
||||
const thu = await db.prepare(
|
||||
"SELECT present FROM attendance WHERE worker_id=? AND work_date='2026-08-27'",
|
||||
).get(workerId) as { present: boolean };
|
||||
assertEquals(thu.present, true);
|
||||
});
|
||||
Deno.test("armar jueves prellena jue-vie-sáb y congela requerida", () => {
|
||||
const db = mem();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
assembleWeek(db, weekId, "2026-08-27");
|
||||
const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
status: string;
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
};
|
||||
assertEquals(week.status, "assembled");
|
||||
assertEquals(week.required_net, 2500);
|
||||
assertEquals(week.payable_net, 2500);
|
||||
const thu = db.prepare(
|
||||
"SELECT present FROM attendance WHERE worker_id=1 AND work_date='2026-08-27'",
|
||||
).get() as { present: number };
|
||||
assertEquals(thu.present, 1);
|
||||
});
|
||||
|
||||
Deno.test("falta viernes baja a pagar y no toca requerida", async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const { projectId, workerId } = await seedFixture(db);
|
||||
await db.prepare(
|
||||
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-24', true)",
|
||||
).run(workerId, projectId);
|
||||
await db.prepare(
|
||||
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)",
|
||||
).run(workerId, projectId);
|
||||
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||
await assembleWeek(db, weekId, "2026-08-27");
|
||||
const r = await setAttendance(db, TENANT_ID, {
|
||||
project_id: projectId,
|
||||
worker_id: workerId,
|
||||
work_date: "2026-08-28",
|
||||
present: false,
|
||||
});
|
||||
assertEquals("ok" in r && r.ok, true);
|
||||
const week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
};
|
||||
assertEquals(week.required_net, 2500);
|
||||
assertEquals(week.payable_net, 2000);
|
||||
});
|
||||
Deno.test("falta viernes baja a pagar y no toca requerida", () => {
|
||||
const db = mem();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
assembleWeek(db, weekId, "2026-08-27");
|
||||
const r = setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-28", present: false });
|
||||
assertEquals("ok" in r && r.ok, true);
|
||||
const week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
};
|
||||
assertEquals(week.required_net, 2500);
|
||||
assertEquals(week.payable_net, 2000);
|
||||
});
|
||||
|
||||
Deno.test("préstamo no baja saldo al armar; sí al pagar", async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const { projectId, workerId } = await seedFixture(db);
|
||||
for (const day of ["2026-08-24", "2026-08-25", "2026-08-26"]) {
|
||||
await db.prepare(
|
||||
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, true)",
|
||||
).run(workerId, projectId, day);
|
||||
}
|
||||
await createLoan(db, TENANT_ID, { worker_id: workerId, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" });
|
||||
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||
await assembleWeek(db, weekId, "2026-08-27");
|
||||
const before = await db.prepare("SELECT balance FROM loans WHERE worker_id=?").get(workerId) as { balance: number };
|
||||
assertEquals(before.balance, 2200);
|
||||
const week = await db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number };
|
||||
assertEquals(week.required_net, 800);
|
||||
await payWeek(db, weekId, "2026-08-29");
|
||||
const after = await db.prepare("SELECT balance FROM loans WHERE worker_id=?").get(workerId) as { balance: number };
|
||||
assertEquals(after.balance, 0);
|
||||
});
|
||||
Deno.test("préstamo no baja saldo al armar; sí al pagar", () => {
|
||||
const db = mem();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-26', 1)").run();
|
||||
createLoan(db, 1, { worker_id: 1, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" });
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
assembleWeek(db, weekId, "2026-08-27");
|
||||
const before = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number };
|
||||
assertEquals(before.balance, 2200);
|
||||
const week = db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number };
|
||||
assertEquals(week.required_net, 800);
|
||||
payWeek(db, weekId);
|
||||
const after = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number };
|
||||
assertEquals(after.balance, 0);
|
||||
});
|
||||
|
||||
Deno.test("tablero: recuperación de préstamos no es sobrante", async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const { projectId, workerId } = await seedFixture(db);
|
||||
await db.prepare(
|
||||
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-24', true)",
|
||||
).run(workerId, projectId);
|
||||
await db.prepare(
|
||||
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, '2026-08-25', true)",
|
||||
).run(workerId, projectId);
|
||||
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||
await assembleWeek(db, weekId, "2026-08-27");
|
||||
await createLoan(db, TENANT_ID, { worker_id: workerId, delivered: 1000, commission_pct: 20, grantIso: "2026-08-24" });
|
||||
const bundle = await getWeekBundle(db, TENANT_ID, "2026-08-24");
|
||||
const board = bundle.board as {
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
loan_recovery: number;
|
||||
faltante: number;
|
||||
};
|
||||
assertEquals(board.required_net, 2500);
|
||||
assertEquals(board.loan_recovery, 1200);
|
||||
assertEquals(board.payable_net, 1300);
|
||||
assertEquals(board.faltante, 0);
|
||||
});
|
||||
Deno.test("tablero: recuperación de préstamos no es sobrante", () => {
|
||||
const db = mem();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
assembleWeek(db, weekId, "2026-08-27");
|
||||
createLoan(db, 1, { worker_id: 1, delivered: 1000, commission_pct: 20, grantIso: "2026-08-24" });
|
||||
const bundle = getWeekBundle(db, 1, "2026-08-24");
|
||||
const board = bundle.board as {
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
loan_recovery: number;
|
||||
faltante: number;
|
||||
};
|
||||
assertEquals(board.required_net, 2500);
|
||||
assertEquals(board.loan_recovery, 1200);
|
||||
assertEquals(board.payable_net, 1300);
|
||||
assertEquals(board.faltante, 0);
|
||||
});
|
||||
|
||||
Deno.test("un día no se marca en dos obras", 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("un día no se marca en dos obras", () => {
|
||||
const db = mem();
|
||||
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run();
|
||||
setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-24", present: true });
|
||||
const blocked = setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-24", present: true });
|
||||
assertEquals("error" in blocked, true);
|
||||
});
|
||||
|
||||
Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const { companyId, projectId, workerId } = await seedFixture(db);
|
||||
const project2Id = await addSecondProject(db, companyId);
|
||||
await setAttendance(db, TENANT_ID, { project_id: project2Id, worker_id: workerId, work_date: "2026-08-27", present: true });
|
||||
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||
await assembleWeek(db, weekId, "2026-08-27");
|
||||
const onA = await db.prepare(
|
||||
"SELECT present FROM attendance WHERE worker_id=? AND project_id=? AND work_date='2026-08-27'",
|
||||
).get(workerId, projectId) as { present: boolean } | undefined;
|
||||
assertEquals(onA, undefined);
|
||||
const onB = await db.prepare(
|
||||
"SELECT present FROM attendance WHERE worker_id=? AND project_id=? AND work_date='2026-08-27'",
|
||||
).get(workerId, project2Id) as { present: boolean };
|
||||
assertEquals(onB.present, true);
|
||||
});
|
||||
Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", () => {
|
||||
const db = mem();
|
||||
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run();
|
||||
setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-27", present: true });
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
assembleWeek(db, weekId, "2026-08-27");
|
||||
const onA = db.prepare(
|
||||
"SELECT present FROM attendance WHERE worker_id=1 AND project_id=1 AND work_date='2026-08-27'",
|
||||
).get() as { present: number } | undefined;
|
||||
assertEquals(onA, undefined);
|
||||
const onB = db.prepare(
|
||||
"SELECT present FROM attendance WHERE worker_id=1 AND project_id=2 AND work_date='2026-08-27'",
|
||||
).get() as { present: number };
|
||||
assertEquals(onB.present, 1);
|
||||
});
|
||||
|
||||
Deno.test("destajo: estimado del corte, realizado menor, remanente y extra viernes", async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const { companyId, projectId, workerId } = await seedFixture(db);
|
||||
// Igual que el original: el jornalero de la fixture no debe seguir en
|
||||
// el roster de "obra", para que el requerido sea solo el destajo.
|
||||
await db.prepare("DELETE FROM assignments WHERE worker_id=?").run(workerId);
|
||||
const worker2Id = await addSecondWorker(db, companyId);
|
||||
const weekId = await ensureWeek(db, TENANT_ID, "2026-08-24");
|
||||
const job = await createDestajoJob(db, TENANT_ID, weekId, {
|
||||
project_id: projectId,
|
||||
worker_id: worker2Id,
|
||||
concepto: "Muro tablaroca",
|
||||
unit_code: "m2",
|
||||
qty_total_estimated: 200,
|
||||
unit_price: 100,
|
||||
qty_planned: 80,
|
||||
});
|
||||
await assembleWeek(db, weekId, "2026-08-27");
|
||||
let week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
};
|
||||
assertEquals(week.required_net, 8000);
|
||||
const cut = await db.prepare("SELECT id FROM destajo_cut_lines WHERE job_id=?").get(job.id) as { id: number };
|
||||
await patchDestajoCut(db, weekId, cut.id, { qty_actual: 50 });
|
||||
week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
};
|
||||
assertEquals(week.required_net, 8000);
|
||||
assertEquals(week.payable_net, 5000);
|
||||
await patchDestajoCut(db, weekId, cut.id, { qty_extra: 10 });
|
||||
week = await db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
};
|
||||
assertEquals(week.payable_net, 6000);
|
||||
await payWeek(db, weekId, "2026-08-29");
|
||||
assertEquals(await jobRemainder(db, job.id), 140);
|
||||
const nextId = await ensureWeek(db, TENANT_ID, addDays("2026-08-24", 7));
|
||||
assertExists(nextId);
|
||||
const bundle = await getWeekBundle(db, TENANT_ID, "2026-08-31");
|
||||
assertEquals((bundle.destajo_jobs as { remainder: number }[])[0].remainder, 140);
|
||||
|
||||
Deno.test("destajo: estimado del corte, realizado menor, remanente y extra viernes", () => {
|
||||
const db = mem();
|
||||
db.prepare("DELETE FROM assignments WHERE worker_id=1").run();
|
||||
db.prepare(
|
||||
"INSERT INTO workers (id, first_name, last_name_p, work_type, daily_wage, tenant_id) VALUES (2, 'Luis', 'Destajo', 'D', 0, 1)",
|
||||
).run();
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
const job = createDestajoJob(db, 1, weekId, {
|
||||
project_id: 1,
|
||||
worker_id: 2,
|
||||
concepto: "Muro tablaroca",
|
||||
unit_code: "m2",
|
||||
qty_total_estimated: 200,
|
||||
unit_price: 100,
|
||||
qty_planned: 80,
|
||||
});
|
||||
assembleWeek(db, weekId, "2026-08-27");
|
||||
let week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
};
|
||||
assertEquals(week.required_net, 8000);
|
||||
const cut = db.prepare("SELECT id FROM destajo_cut_lines WHERE job_id=?").get(job.id) as { id: number };
|
||||
patchDestajoCut(db, weekId, cut.id, { qty_actual: 50 });
|
||||
week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
};
|
||||
assertEquals(week.required_net, 8000);
|
||||
assertEquals(week.payable_net, 5000);
|
||||
patchDestajoCut(db, weekId, cut.id, { qty_extra: 10 });
|
||||
week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
};
|
||||
assertEquals(week.payable_net, 6000);
|
||||
payWeek(db, weekId);
|
||||
assertEquals(jobRemainder(db, job.id), 140);
|
||||
const nextId = ensureWeek(db, 1, addDays("2026-08-24", 7));
|
||||
assertExists(nextId);
|
||||
const bundle = getWeekBundle(db, 1, "2026-08-31");
|
||||
assertEquals((bundle.destajo_jobs as { remainder: number }[])[0].remainder, 140);
|
||||
});
|
||||
|
|
|
|||
46
api/pdf.ts
46
api/pdf.ts
|
|
@ -1,10 +1,13 @@
|
|||
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 } from "./config.ts";
|
||||
import { config, PDFS_DIR } from "./config.ts";
|
||||
import { decryptBytes } from "./docs_crypto.ts";
|
||||
import { fullName, frontName } from "./mx.ts";
|
||||
import type { Db } from "./db.ts";
|
||||
import { badgeJobPdfKey, getObject, putObject, workerDocKey } from "./storage.ts";
|
||||
import { workerDir } from "./db.ts";
|
||||
|
||||
const CM = 28.346456692913385;
|
||||
const CARD_W = 6.7 * CM;
|
||||
|
|
@ -50,15 +53,15 @@ function hexRgb(hex: string) {
|
|||
);
|
||||
}
|
||||
|
||||
export async function loadCurrentPhoto(db: Db, workerId: number): Promise<Uint8Array | null> {
|
||||
const doc = await db.prepare(
|
||||
export async function loadCurrentPhoto(db: Database, workerId: number): Promise<Uint8Array | null> {
|
||||
const doc = db.prepare(
|
||||
`SELECT storage_name, iv FROM documents
|
||||
WHERE worker_id = ? AND type_code = 'foto' AND is_current = true
|
||||
WHERE worker_id = ? AND type_code = 'foto' AND is_current = 1
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
).get(workerId) as { storage_name: string; iv: string } | undefined;
|
||||
if (!doc) return null;
|
||||
try {
|
||||
const enc = await getObject(workerDocKey(workerId, doc.storage_name));
|
||||
const enc = await Deno.readFile(join(workerDir(workerId), doc.storage_name));
|
||||
return await decryptBytes(doc.iv, enc);
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -96,11 +99,11 @@ function drawCentered(
|
|||
}
|
||||
|
||||
export async function generateBadgePdf(
|
||||
db: Db,
|
||||
db: Database,
|
||||
projectId: number,
|
||||
workerIds: number[],
|
||||
): Promise<Uint8Array> {
|
||||
const project = await db.prepare(
|
||||
const project = db.prepare(
|
||||
"SELECT name, code, theme_id, logo_left_path, logo_right_path FROM projects WHERE id = ?",
|
||||
).get(projectId) as {
|
||||
name: string;
|
||||
|
|
@ -111,14 +114,15 @@ export async function generateBadgePdf(
|
|||
} | undefined;
|
||||
if (!project) throw new Error("Proyecto no encontrado");
|
||||
|
||||
const workers = await db.prepare(
|
||||
const placeholders = workerIds.map(() => "?").join(",");
|
||||
const workers = 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 = ANY(?)`,
|
||||
).all(workerIds) as WorkerRow[];
|
||||
WHERE w.id IN (${placeholders})`,
|
||||
).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[];
|
||||
|
|
@ -131,12 +135,12 @@ export async function generateBadgePdf(
|
|||
let logoR: PDFImage | null = null;
|
||||
if (project.logo_left_path) {
|
||||
try {
|
||||
logoL = await embedMaybe(pdf, await getObject(project.logo_left_path));
|
||||
logoL = await embedMaybe(pdf, await Deno.readFile(project.logo_left_path));
|
||||
} catch { /* text fallback */ }
|
||||
}
|
||||
if (project.logo_right_path) {
|
||||
try {
|
||||
logoR = await embedMaybe(pdf, await getObject(project.logo_right_path));
|
||||
logoR = await embedMaybe(pdf, await Deno.readFile(project.logo_right_path));
|
||||
} catch { /* text fallback */ }
|
||||
}
|
||||
|
||||
|
|
@ -234,6 +238,7 @@ 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);
|
||||
|
||||
|
|
@ -269,6 +274,8 @@ function drawBack(
|
|||
projectName: string,
|
||||
projectCode: string,
|
||||
) {
|
||||
const cx = x + CARD_W / 2;
|
||||
const cy = y + CARD_H / 2;
|
||||
page.drawRectangle({
|
||||
x,
|
||||
y,
|
||||
|
|
@ -277,7 +284,11 @@ 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,
|
||||
|
|
@ -332,9 +343,10 @@ function drawBack(
|
|||
}
|
||||
|
||||
export async function saveJobPdf(bytes: Uint8Array, jobId: number): Promise<string> {
|
||||
const key = badgeJobPdfKey(jobId);
|
||||
await putObject(key, bytes);
|
||||
return key;
|
||||
await mkdir(PDFS_DIR, { recursive: true });
|
||||
const path = join(PDFS_DIR, `gafetes-${jobId}.pdf`);
|
||||
await Deno.writeFile(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
function wrap(text: string, font: PDFFont, size: number, max: number): string[] {
|
||||
|
|
|
|||
138
api/pg.ts
138
api/pg.ts
|
|
@ -1,138 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,21 +1,13 @@
|
|||
import postgres, { createPool, PgDb } from "./pg.ts";
|
||||
import { Database } from "@db/sqlite";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { DATA_DIR, PLATFORM_DB_PATH } from "./config.ts";
|
||||
import { config } from "./config.ts";
|
||||
import { hashPassword } from "./crypto.ts";
|
||||
import { runLiquibase } from "./liquibase.ts";
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export type PlatformDb = Database;
|
||||
|
||||
let pool: postgres.Sql | null = null;
|
||||
|
||||
export type PlatformDb = PgDb;
|
||||
let platformDb: Database | null = null;
|
||||
|
||||
export type PlatformUser = {
|
||||
id: number;
|
||||
|
|
@ -24,25 +16,70 @@ export type PlatformUser = {
|
|||
status: string;
|
||||
};
|
||||
|
||||
export async function getPlatformDb(): Promise<PlatformDb> {
|
||||
if (!pool) {
|
||||
pool = createPool(config.databaseUrlPlatform, { max: 10 });
|
||||
await pool`SELECT 1`;
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
return new PgDb(pool);
|
||||
}
|
||||
|
||||
export async function closePlatformDb(): Promise<void> {
|
||||
await pool?.end({ timeout: 5 });
|
||||
pool = null;
|
||||
}
|
||||
|
||||
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(
|
||||
export function listTenants(database: Database) {
|
||||
return 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,
|
||||
|
|
@ -64,10 +101,8 @@ export async function listTenants(database: PlatformDb) {
|
|||
}[];
|
||||
}
|
||||
|
||||
export async function tenantByCode(database: PlatformDb, code: string) {
|
||||
return await database.prepare("SELECT * FROM tenants WHERE code = ?").get(
|
||||
code.trim().toUpperCase(),
|
||||
) as
|
||||
export function tenantByCode(database: Database, code: string) {
|
||||
return database.prepare("SELECT * FROM tenants WHERE code = ?").get(code.trim().toUpperCase()) as
|
||||
| {
|
||||
id: number;
|
||||
code: string;
|
||||
|
|
@ -80,8 +115,8 @@ export async function tenantByCode(database: PlatformDb, code: string) {
|
|||
| undefined;
|
||||
}
|
||||
|
||||
export async function tenantById(database: PlatformDb, id: number) {
|
||||
return await database.prepare("SELECT * FROM tenants WHERE id = ?").get(id) as
|
||||
export function tenantById(database: Database, id: number) {
|
||||
return database.prepare("SELECT * FROM tenants WHERE id = ?").get(id) as
|
||||
| {
|
||||
id: number;
|
||||
code: string;
|
||||
|
|
@ -95,6 +130,6 @@ export async function tenantById(database: PlatformDb, id: number) {
|
|||
| undefined;
|
||||
}
|
||||
|
||||
export async function lastInsertId(database: PlatformDb): Promise<number> {
|
||||
return await database.lastInsertId();
|
||||
export function lastInsertId(database: Database): number {
|
||||
return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id);
|
||||
}
|
||||
|
|
|
|||
51
api/redis.ts
51
api/redis.ts
|
|
@ -1,51 +0,0 @@
|
|||
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;
|
||||
}
|
||||
201
api/saas.ts
201
api/saas.ts
|
|
@ -1,6 +1,6 @@
|
|||
import type { Database } from "@db/sqlite";
|
||||
import { generateSecurePassword, hashPassword } from "./crypto.ts";
|
||||
import { getCoreDb, type Db } from "./db.ts";
|
||||
import { withIamOwner } from "./iam_db.ts";
|
||||
import { lastInsertId as appLastId } from "./db.ts";
|
||||
import {
|
||||
lastInsertId as platformLastId,
|
||||
listTenants as listTenantsRaw,
|
||||
|
|
@ -107,29 +107,29 @@ export function codeDateStamp(when: Date = new Date()): string {
|
|||
return `${yy}${mm}`;
|
||||
}
|
||||
|
||||
async function codeExists(platformDb: PlatformDb, coreDb: Db, code: string): Promise<boolean> {
|
||||
return !!await tenantByCode(platformDb, code) || !!await companyByCode(coreDb, code);
|
||||
function codeExists(platformDb: PlatformDb, appDb: Database, code: string): boolean {
|
||||
return !!tenantByCode(platformDb, code) || !!companyByCode(appDb, code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Código único: prefijo del nombre comercial + YYMM de creación.
|
||||
* Ej. «ARCTEC» en ago-2026 → ARCT2608. Si choca, sufijo numérico.
|
||||
*/
|
||||
export async function allocateUniqueTenantCode(
|
||||
export function allocateUniqueTenantCode(
|
||||
platformDb: PlatformDb,
|
||||
coreDb: Db,
|
||||
appDb: Database,
|
||||
nombreComercial: string,
|
||||
createdAt: Date = new Date(),
|
||||
): Promise<string> {
|
||||
): string {
|
||||
const base = shortCodeFromCommercialName(nombreComercial);
|
||||
const stamp = codeDateStamp(createdAt);
|
||||
const primary = `${base}${stamp}`;
|
||||
if (!await codeExists(platformDb, coreDb, primary)) return primary;
|
||||
if (!codeExists(platformDb, appDb, 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 && !await codeExists(platformDb, coreDb, candidate)) return candidate;
|
||||
if (candidate.length <= 16 && !codeExists(platformDb, appDb, candidate)) return candidate;
|
||||
}
|
||||
return `E${stamp}${Date.now().toString(36).toUpperCase().slice(-4)}`;
|
||||
}
|
||||
|
|
@ -178,16 +178,16 @@ export function requireSaasCompanyFields(
|
|||
return null;
|
||||
}
|
||||
|
||||
export async function listTenants(platformDb: PlatformDb) {
|
||||
return await listTenantsRaw(platformDb);
|
||||
export function listTenants(platformDb: PlatformDb) {
|
||||
return listTenantsRaw(platformDb);
|
||||
}
|
||||
|
||||
export async function getTenantDetail(
|
||||
export function getTenantDetail(
|
||||
platformDb: PlatformDb,
|
||||
coreDb: Db,
|
||||
appDb: Database,
|
||||
tenantId: number,
|
||||
): Promise<TenantDetail | null> {
|
||||
const t = await platformDb.prepare(
|
||||
): TenantDetail | null {
|
||||
const t = 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,11 +211,7 @@ export async function getTenantDetail(
|
|||
| undefined;
|
||||
if (!t) return null;
|
||||
|
||||
// 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(
|
||||
const company = appDb.prepare(
|
||||
`SELECT id, code, name,
|
||||
COALESCE(rfc, '') AS rfc,
|
||||
COALESCE(razon_social, '') AS razon_social,
|
||||
|
|
@ -234,23 +230,20 @@ export async function getTenantDetail(
|
|||
FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`,
|
||||
).get(tenantId) as CompanySnapshot | undefined;
|
||||
|
||||
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>>;
|
||||
return rows.map((a) => ({
|
||||
id: Number(a.id),
|
||||
username: String(a.username),
|
||||
display_name: String(a.display_name),
|
||||
email: String(a.email || ""),
|
||||
role: String(a.role),
|
||||
must_change_password: Boolean(a.must_change_password),
|
||||
created_at: String(a.created_at),
|
||||
}));
|
||||
});
|
||||
const admins = (appDb.prepare(
|
||||
`SELECT id, username, display_name, COALESCE(email, '') AS email, role,
|
||||
COALESCE(must_change_password, 0) AS must_change_password, created_at
|
||||
FROM users WHERE tenant_id = ? AND role = 'tenant_admin'
|
||||
ORDER BY id`,
|
||||
).all(tenantId) as Array<Record<string, unknown>>).map((a) => ({
|
||||
id: Number(a.id),
|
||||
username: String(a.username),
|
||||
display_name: String(a.display_name),
|
||||
email: String(a.email || ""),
|
||||
role: String(a.role),
|
||||
must_change_password: Boolean(a.must_change_password),
|
||||
created_at: String(a.created_at),
|
||||
}));
|
||||
|
||||
return {
|
||||
...t,
|
||||
|
|
@ -259,19 +252,9 @@ export async 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,
|
||||
coreDb: Db,
|
||||
appDb: Database,
|
||||
input: CreateTenantInput,
|
||||
): Promise<{
|
||||
tenant?: { id: number; code: string; name: string; status: string };
|
||||
|
|
@ -288,7 +271,7 @@ export async function createTenant(
|
|||
});
|
||||
if (completeErr) return { error: completeErr };
|
||||
|
||||
const code = await allocateUniqueTenantCode(platformDb, coreDb, profile.nombre_comercial);
|
||||
const code = allocateUniqueTenantCode(platformDb, appDb, profile.nombre_comercial);
|
||||
|
||||
const displayName = profile.nombre_comercial;
|
||||
const adminUser = adminUsernameForCode(code);
|
||||
|
|
@ -296,7 +279,7 @@ export async function createTenant(
|
|||
const contactEmail = profile.email;
|
||||
const adminName = trim(input.admin_display_name) || contactName;
|
||||
|
||||
const userClash = await withIamOwner((iam) => iam.prepare("SELECT id FROM users WHERE username = ?").get(adminUser));
|
||||
const userClash = appDb.prepare("SELECT id FROM users WHERE username = ?").get(adminUser);
|
||||
if (userClash) return { error: `Ya existe el usuario ${adminUser}` };
|
||||
|
||||
const plan = trim(input.plan) || "trial";
|
||||
|
|
@ -305,65 +288,53 @@ export async function createTenant(
|
|||
const notes = trim(input.notes);
|
||||
const temporaryPassword = generateSecurePassword();
|
||||
|
||||
// Paso 1: tenant en panels_platform.
|
||||
await platformDb.prepare(
|
||||
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 = await platformLastId(platformDb);
|
||||
const tenantId = platformLastId(platformDb);
|
||||
|
||||
let companyId: number | undefined;
|
||||
try {
|
||||
// Paso 2: empresa principal en core (rol owner: alta administrativa
|
||||
// cruzando tenants, no una operación de un usuario ya autenticado).
|
||||
companyId = await getCoreDb().then((core) =>
|
||||
core.prepare(
|
||||
`INSERT INTO companies (
|
||||
code, name, parent_id, kind, status, tenant_id,
|
||||
razon_social, nombre_comercial, rfc, regimen_fiscal, registro_patronal, clase_riesgo,
|
||||
domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro
|
||||
) VALUES (?, ?, NULL, 'principal', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
code,
|
||||
displayName,
|
||||
tenantId,
|
||||
profile.razon_social,
|
||||
profile.nombre_comercial,
|
||||
profile.rfc,
|
||||
profile.regimen_fiscal,
|
||||
profile.registro_patronal,
|
||||
profile.clase_riesgo,
|
||||
profile.domicilio_fiscal,
|
||||
profile.codigo_postal,
|
||||
profile.ciudad,
|
||||
profile.estado,
|
||||
profile.telefono,
|
||||
contactEmail,
|
||||
profile.representante_legal,
|
||||
profile.giro,
|
||||
).then(() => core.lastInsertId())
|
||||
appDb.prepare(
|
||||
`INSERT INTO companies (
|
||||
code, name, parent_id, kind, status, tenant_id,
|
||||
razon_social, nombre_comercial, rfc, regimen_fiscal, registro_patronal, clase_riesgo,
|
||||
domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro
|
||||
) VALUES (?, ?, NULL, 'principal', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
code,
|
||||
displayName,
|
||||
tenantId,
|
||||
profile.razon_social,
|
||||
profile.nombre_comercial,
|
||||
profile.rfc,
|
||||
profile.regimen_fiscal,
|
||||
profile.registro_patronal,
|
||||
profile.clase_riesgo,
|
||||
profile.domicilio_fiscal,
|
||||
profile.codigo_postal,
|
||||
profile.ciudad,
|
||||
profile.estado,
|
||||
profile.telefono,
|
||||
contactEmail,
|
||||
profile.representante_legal,
|
||||
profile.giro,
|
||||
);
|
||||
const companyId = appLastId(appDb);
|
||||
|
||||
// Paso 3: admin del tenant en iam.
|
||||
const hash = await hashPassword(temporaryPassword);
|
||||
await withIamOwner((iam) =>
|
||||
iam.prepare(
|
||||
`INSERT INTO users (
|
||||
username, password_hash, display_name, company_id, tenant_id, role_code,
|
||||
must_change_password, email
|
||||
) VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, ?)`,
|
||||
).run(adminUser, hash, adminName, companyId, tenantId, contactEmail)
|
||||
);
|
||||
appDb.prepare(
|
||||
`INSERT INTO users (
|
||||
username, password_hash, display_name, company_id, tenant_id, role,
|
||||
must_change_password, email
|
||||
) VALUES (?, ?, ?, ?, ?, 'tenant_admin', 1, ?)`,
|
||||
).run(adminUser, hash, adminName, companyId, tenantId, contactEmail);
|
||||
} catch (e) {
|
||||
// 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);
|
||||
platformDb.prepare("DELETE FROM tenants WHERE id = ?").run(tenantId);
|
||||
return { error: e instanceof Error ? e.message : "Error al crear empresa" };
|
||||
}
|
||||
|
||||
const tenant = await platformDb.prepare(
|
||||
const tenant = platformDb.prepare(
|
||||
"SELECT id, code, name, status FROM tenants WHERE id = ?",
|
||||
).get(tenantId) as { id: number; code: string; name: string; status: string };
|
||||
|
||||
|
|
@ -388,16 +359,16 @@ export async function createTenant(
|
|||
};
|
||||
}
|
||||
|
||||
export async function updateTenant(
|
||||
export function updateTenant(
|
||||
platformDb: PlatformDb,
|
||||
coreDb: Db,
|
||||
appDb: Database,
|
||||
tenantId: number,
|
||||
input: UpdateTenantInput,
|
||||
): Promise<{ error?: string }> {
|
||||
const current = await tenantById(platformDb, tenantId);
|
||||
): { error?: string } {
|
||||
const current = tenantById(platformDb, tenantId);
|
||||
if (!current) return { error: "Empresa no encontrada" };
|
||||
|
||||
const company = await coreDb.prepare(
|
||||
const company = appDb.prepare(
|
||||
`SELECT * FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`,
|
||||
).get(tenantId) as Record<string, unknown> | undefined;
|
||||
|
||||
|
|
@ -427,7 +398,7 @@ export async function updateTenant(
|
|||
};
|
||||
const profile = normalizeCompanyProfile(merged, current.name);
|
||||
|
||||
const row = await platformDb.prepare(
|
||||
const row = 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 = ?`,
|
||||
|
|
@ -479,13 +450,13 @@ export async function updateTenant(
|
|||
|
||||
const displayName = profile.nombre_comercial || profile.razon_social || current.name;
|
||||
|
||||
await platformDb.prepare(
|
||||
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) {
|
||||
await coreDb.prepare(
|
||||
appDb.prepare(
|
||||
`UPDATE companies SET
|
||||
name = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?,
|
||||
registro_patronal = ?, clase_riesgo = ?, domicilio_fiscal = ?, codigo_postal = ?,
|
||||
|
|
@ -517,7 +488,7 @@ export async function updateTenant(
|
|||
/** Regenera contraseña del admin, marca must_change_password y opcionalmente envía correo. */
|
||||
export async function issueTenantAdminAccess(
|
||||
platformDb: PlatformDb,
|
||||
coreDb: Db,
|
||||
appDb: Database,
|
||||
tenantId: number,
|
||||
opts: { userId?: number; send_email?: boolean } = {},
|
||||
): Promise<{
|
||||
|
|
@ -525,7 +496,7 @@ export async function issueTenantAdminAccess(
|
|||
email?: { sent: boolean; error?: string };
|
||||
error?: string;
|
||||
}> {
|
||||
const detail = await getTenantDetail(platformDb, coreDb, tenantId);
|
||||
const detail = getTenantDetail(platformDb, appDb, tenantId);
|
||||
if (!detail) return { error: "Empresa no encontrada" };
|
||||
const admin = opts.userId
|
||||
? detail.admins.find((a) => a.id === opts.userId)
|
||||
|
|
@ -534,11 +505,9 @@ export async function issueTenantAdminAccess(
|
|||
|
||||
const temporaryPassword = generateSecurePassword();
|
||||
const hash = await hashPassword(temporaryPassword);
|
||||
await withIamOwner((iam) =>
|
||||
iam.prepare(
|
||||
`UPDATE users SET password_hash = ?, must_change_password = true WHERE id = ? AND tenant_id = ?`,
|
||||
).run(hash, admin.id, tenantId)
|
||||
);
|
||||
appDb.prepare(
|
||||
`UPDATE users SET password_hash = ?, must_change_password = 1 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 };
|
||||
|
|
@ -596,16 +565,16 @@ async function sendAccessEmail(
|
|||
attachments: accessEmailAttachments(),
|
||||
});
|
||||
if (result.sent) {
|
||||
await platformDb.prepare(
|
||||
`UPDATE tenants SET access_sent_at = now() WHERE id = ?`,
|
||||
platformDb.prepare(
|
||||
`UPDATE tenants SET access_sent_at = datetime('now') WHERE id = ?`,
|
||||
).run(opts.tenantId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function tenantAccessBlocked(platformDb: PlatformDb, tenantId: number | null): Promise<string | null> {
|
||||
export function tenantAccessBlocked(platformDb: PlatformDb, tenantId: number | null): string | null {
|
||||
if (tenantId == null) return "Cuenta sin empresa asignada";
|
||||
const t = await platformDb.prepare(
|
||||
const t = 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";
|
||||
|
|
|
|||
384
api/schema.sql
Normal file
384
api/schema.sql
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
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
26
api/scope.ts
|
|
@ -1,26 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
#!/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);
|
||||
|
|
@ -1,447 +0,0 @@
|
|||
#!/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);
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
/**
|
||||
* Sonda S3/R2: ListObjects (o put/get/delete). HeadBucket no se usa:
|
||||
* R2 suele devolver 403 con tokens acotados al bucket.
|
||||
*
|
||||
* Desde api/:
|
||||
* deno run --allow-net --allow-env --allow-read --allow-write --allow-sys scripts/verify-storage.ts
|
||||
*
|
||||
* Requiere S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY.
|
||||
* Si REQUIRE_S3=1 y no hay credenciales, sale con error (staging/prod).
|
||||
* 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 - ping bucket: ${ping.error ?? "sin detalle"}`);
|
||||
Deno.exit(1);
|
||||
}
|
||||
console.log("OK - ping bucket (ListObjects o sonda write)");
|
||||
await probeStorageReadWrite();
|
||||
console.log("OK - put/get/delete de objeto sonda");
|
||||
|
|
@ -1,14 +1,9 @@
|
|||
/**
|
||||
* 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 { withCoreTenant } from "./db.ts";
|
||||
import { getDb } from "./db.ts";
|
||||
import { upsertWorker, storeDocument } from "./excel.ts";
|
||||
import { resolveCompany } from "./companies.ts";
|
||||
import {
|
||||
|
|
@ -21,8 +16,7 @@ import {
|
|||
type WorkerInput,
|
||||
} from "./mx.ts";
|
||||
|
||||
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 FILE = "/mnt/c/Users/betom/Downloads/LISTA COLABORADORES GAFETES.xlsx";
|
||||
const CONS = "BCDFGHJKLMNPQRSTVWXYZ";
|
||||
|
||||
function cell(r: Record<string, unknown>, k: string) {
|
||||
|
|
@ -74,82 +68,81 @@ async function fetchPhoto(url: string): Promise<Uint8Array | null> {
|
|||
}
|
||||
}
|
||||
|
||||
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));
|
||||
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));
|
||||
|
||||
type Job = { sheet: string; status: "activo" | "baja" };
|
||||
const jobs: Job[] = [
|
||||
{ sheet: "ACTUALES", status: "activo" },
|
||||
{ sheet: "HISTORICO", status: "baja" },
|
||||
];
|
||||
type Job = { sheet: string; status: "activo" | "baja" };
|
||||
const jobs: Job[] = [
|
||||
{ sheet: "ACTUALES", status: "activo" },
|
||||
{ sheet: "HISTORICO", status: "baja" },
|
||||
];
|
||||
|
||||
let inserted = 0, existed = 0, skipped = 0, photos = 0, i = 0;
|
||||
let inserted = 0, existed = 0, skipped = 0, photos = 0, i = 0;
|
||||
|
||||
for (const job of jobs) {
|
||||
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(wb.Sheets[job.sheet], { defval: "" });
|
||||
for (const raw of rows) {
|
||||
const first = cell(raw, "NOMBRE");
|
||||
const lastp = cell(raw, "APELLIDO PATERNO");
|
||||
if (!first || !lastp) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
let curp = cell(raw, "CURP");
|
||||
let rfc = cell(raw, "RFC");
|
||||
let nss = cell(raw, "NSS");
|
||||
if (validateCurp(curp)) curp = makeCurp(i);
|
||||
if (validateRfc(rfc)) rfc = makeRfc(curp || makeCurp(i), i);
|
||||
if (validateNss(nss)) nss = makeNss(1000 + i);
|
||||
const ids = { curp, rfc, nss };
|
||||
const risk = canonicalRiskCode(cell(raw, "RIESGO"));
|
||||
const hire = cell(raw, "ALTA").toUpperCase();
|
||||
const input: WorkerInput = {
|
||||
first_name: first,
|
||||
middle_name: cell(raw, "2 NOMBRE") || null,
|
||||
last_name_p: lastp,
|
||||
last_name_m: cell(raw, "APELLIDO MATERNO") || "X",
|
||||
curp: ids.curp,
|
||||
rfc: ids.rfc,
|
||||
nss: ids.nss,
|
||||
phone: cell(raw, "TELEFONO") || String(9981000000 + i),
|
||||
email: cell(raw, "CORREO") || `${slug(first)}.${slug(lastp)}.${i}@pruebas.arctec.local`,
|
||||
address: cell(raw, "DIRECCION") || "Sin domicilio en lista original",
|
||||
blood_type: cell(raw, "TIPO SANGRE") || null,
|
||||
hire_type: hire || "ARCT2608",
|
||||
position: cell(raw, "CARGO") || "AYUDANTE",
|
||||
risk_code: risks.has(risk) ? risk : "rojo",
|
||||
work_type: cell(raw, "TIPO TRABAJO").toUpperCase() === "D" ? "D" : "N",
|
||||
daily_wage: 450,
|
||||
needs_badge: ["si", "sí", "yes"].includes(cell(raw, "GAFETE").toLowerCase()),
|
||||
status: job.status,
|
||||
};
|
||||
const errors = validateWorkerFields(input);
|
||||
const company = (await resolveCompany(db, input)) ?? (await resolveCompany(db, { hire_type: "ARCT2608" }));
|
||||
if (!company) errors.hire_type = "Empresa no encontrada";
|
||||
if (Object.keys(errors).length || !company) {
|
||||
console.log("SKIP", first, lastp, errors);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const n = { ...normalizeWorker(input), hire_type: company.code, company_id: company.id, tenant_id: TENANT_ID };
|
||||
const res = await upsertWorker(db, n, job.status === "activo" ? project?.id ?? null : null);
|
||||
if (res.action === "inserted") inserted++;
|
||||
else existed++;
|
||||
const url = cell(raw, "URL FOTO");
|
||||
if (url) {
|
||||
const photo = await fetchPhoto(url);
|
||||
if (photo) {
|
||||
const mime = photo[0] === 0xff ? "image/jpeg" : "image/png";
|
||||
await storeDocument(db, res.id, "foto", "foto-lista.jpg", mime, photo, null);
|
||||
photos++;
|
||||
}
|
||||
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");
|
||||
const lastp = cell(raw, "APELLIDO PATERNO");
|
||||
if (!first || !lastp) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
let curp = cell(raw, "CURP");
|
||||
let rfc = cell(raw, "RFC");
|
||||
let nss = cell(raw, "NSS");
|
||||
if (validateCurp(curp)) curp = makeCurp(i);
|
||||
if (validateRfc(rfc)) rfc = makeRfc(curp || makeCurp(i), i);
|
||||
if (validateNss(nss)) nss = makeNss(1000 + i);
|
||||
const ids = { curp, rfc, nss };
|
||||
const risk = canonicalRiskCode(cell(raw, "RIESGO"));
|
||||
const hire = cell(raw, "ALTA").toUpperCase();
|
||||
const input: WorkerInput = {
|
||||
first_name: first,
|
||||
middle_name: cell(raw, "2 NOMBRE") || null,
|
||||
last_name_p: lastp,
|
||||
last_name_m: cell(raw, "APELLIDO MATERNO") || "X",
|
||||
curp: ids.curp,
|
||||
rfc: ids.rfc,
|
||||
nss: ids.nss,
|
||||
phone: cell(raw, "TELEFONO") || String(9981000000 + i),
|
||||
email: cell(raw, "CORREO") || `${slug(first)}.${slug(lastp)}.${i}@pruebas.arctec.local`,
|
||||
address: cell(raw, "DIRECCION") || "Sin domicilio en lista original",
|
||||
blood_type: cell(raw, "TIPO SANGRE") || null,
|
||||
hire_type: hire || "ARCT2608",
|
||||
position: cell(raw, "CARGO") || "AYUDANTE",
|
||||
risk_code: risks.has(risk) ? risk : "rojo",
|
||||
work_type: cell(raw, "TIPO TRABAJO").toUpperCase() === "D" ? "D" : "N",
|
||||
daily_wage: 450,
|
||||
needs_badge: ["si", "sí", "yes"].includes(cell(raw, "GAFETE").toLowerCase()),
|
||||
status: job.status,
|
||||
};
|
||||
const errors = validateWorkerFields(input);
|
||||
const company = resolveCompany(db, input) ?? resolveCompany(db, { hire_type: "ARCT2608" });
|
||||
if (!company) errors.hire_type = "Empresa no encontrada";
|
||||
if (Object.keys(errors).length || !company) {
|
||||
console.log("SKIP", first, lastp, errors);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const n = { ...normalizeWorker(input), hire_type: company.code, company_id: company.id };
|
||||
const res = upsertWorker(db, n, job.status === "activo" ? project?.id ?? null : null);
|
||||
if (res.action === "inserted") inserted++;
|
||||
else existed++;
|
||||
const url = cell(raw, "URL FOTO");
|
||||
if (url) {
|
||||
const photo = await fetchPhoto(url);
|
||||
if (photo) {
|
||||
const mime = photo[0] === 0xff ? "image/jpeg" : "image/png";
|
||||
await storeDocument(db, res.id, "foto", "foto-lista.jpg", mime, photo, 1);
|
||||
photos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
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);
|
||||
}
|
||||
42
api/smtp.ts
42
api/smtp.ts
|
|
@ -29,7 +29,7 @@ type SmtpRow = {
|
|||
username: string;
|
||||
password: string;
|
||||
from_address: string;
|
||||
enabled: boolean;
|
||||
enabled: number;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
|
|
@ -48,9 +48,9 @@ function fromEnv(): SmtpSettings {
|
|||
};
|
||||
}
|
||||
|
||||
async function readRow(platformDb: PlatformDb): Promise<SmtpRow | undefined> {
|
||||
function readRow(platformDb: PlatformDb): SmtpRow | undefined {
|
||||
try {
|
||||
return await platformDb.prepare(
|
||||
return platformDb.prepare(
|
||||
`SELECT host, port, username, password, from_address, enabled, updated_at
|
||||
FROM smtp_settings WHERE id = 1`,
|
||||
).get() as SmtpRow | undefined;
|
||||
|
|
@ -59,11 +59,9 @@ async function readRow(platformDb: PlatformDb): Promise<SmtpRow | undefined> {
|
|||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
/** 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);
|
||||
if (row && trim(row.host)) {
|
||||
return {
|
||||
host: trim(row.host),
|
||||
|
|
@ -71,7 +69,7 @@ export async function resolveSmtp(
|
|||
username: trim(row.username),
|
||||
password: row.password ?? "",
|
||||
from_address: trim(row.from_address) || config.smtpFrom,
|
||||
enabled: Boolean(row.enabled),
|
||||
enabled: Number(row.enabled) === 1,
|
||||
updated_at: row.updated_at,
|
||||
source: "db",
|
||||
};
|
||||
|
|
@ -81,20 +79,20 @@ export async function resolveSmtp(
|
|||
return { ...env, enabled: false, source: "none" };
|
||||
}
|
||||
|
||||
export async function smtpConfigured(platformDb: PlatformDb): Promise<boolean> {
|
||||
const s = await resolveSmtp(platformDb);
|
||||
export function smtpConfigured(platformDb: PlatformDb): boolean {
|
||||
const s = resolveSmtp(platformDb);
|
||||
return s.enabled && Boolean(s.host && s.from_address);
|
||||
}
|
||||
|
||||
export async function smtpPublicView(platformDb: PlatformDb): Promise<SmtpPublic> {
|
||||
const s = await resolveSmtp(platformDb);
|
||||
export function smtpPublicView(platformDb: PlatformDb): SmtpPublic {
|
||||
const s = resolveSmtp(platformDb);
|
||||
return {
|
||||
host: s.host,
|
||||
port: s.port,
|
||||
username: s.username,
|
||||
from_address: s.from_address,
|
||||
enabled: s.enabled,
|
||||
configured: await smtpConfigured(platformDb),
|
||||
configured: smtpConfigured(platformDb),
|
||||
has_password: Boolean(s.password),
|
||||
updated_at: s.updated_at ?? null,
|
||||
source: s.source,
|
||||
|
|
@ -117,10 +115,10 @@ function looksLikeEmailFrom(value: string): boolean {
|
|||
return /(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(value);
|
||||
}
|
||||
|
||||
export async function saveSmtpSettings(
|
||||
export function saveSmtpSettings(
|
||||
platformDb: PlatformDb,
|
||||
input: SaveSmtpInput,
|
||||
): Promise<{ error?: string; settings?: SmtpPublic }> {
|
||||
): { error?: string; settings?: SmtpPublic } {
|
||||
const host = trim(input.host);
|
||||
const fromAddress = trim(input.from_address);
|
||||
const username = trim(input.username);
|
||||
|
|
@ -138,15 +136,15 @@ export async function saveSmtpSettings(
|
|||
};
|
||||
}
|
||||
|
||||
const current = await readRow(platformDb);
|
||||
const current = readRow(platformDb);
|
||||
let password = input.password ?? "";
|
||||
if ((input.keep_password || password === "") && current?.password) {
|
||||
password = current.password;
|
||||
}
|
||||
|
||||
await platformDb.prepare(
|
||||
platformDb.prepare(
|
||||
`INSERT INTO smtp_settings (id, host, port, username, password, from_address, enabled, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, now())
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
host = excluded.host,
|
||||
port = excluded.port,
|
||||
|
|
@ -154,8 +152,8 @@ export async function saveSmtpSettings(
|
|||
password = excluded.password,
|
||||
from_address = excluded.from_address,
|
||||
enabled = excluded.enabled,
|
||||
updated_at = now()`,
|
||||
).run(host, port, username, password, fromAddress, enabled);
|
||||
updated_at = datetime('now')`,
|
||||
).run(host, port, username, password, fromAddress, enabled ? 1 : 0);
|
||||
|
||||
return { settings: await smtpPublicView(platformDb) };
|
||||
return { settings: smtpPublicView(platformDb) };
|
||||
}
|
||||
|
|
|
|||
180
api/storage.ts
180
api/storage.ts
|
|
@ -1,180 +0,0 @@
|
|||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
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";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function s3Err(e: unknown): string {
|
||||
if (e && typeof e === "object") {
|
||||
const o = e as {
|
||||
name?: string;
|
||||
message?: string;
|
||||
Code?: string;
|
||||
$metadata?: { httpStatusCode?: number };
|
||||
};
|
||||
return [o.name, o.Code, o.$metadata?.httpStatusCode, o.message].filter(Boolean).join(": ");
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* R2: HeadBucket suele dar 403 con token de Account API acotado al bucket.
|
||||
* ListObjectsV2 (o un put/get sonda) es lo que realmente usamos en runtime.
|
||||
*/
|
||||
export async function pingStorage(): Promise<StoragePing> {
|
||||
if (!s3Configured()) {
|
||||
try {
|
||||
await mkdir(join(DATA_DIR, "local-objects"), { recursive: true });
|
||||
return { configured: false, ok: true, backend: "local" };
|
||||
} catch (e) {
|
||||
return { configured: false, ok: false, backend: "local", error: s3Err(e) };
|
||||
}
|
||||
}
|
||||
try {
|
||||
await getClient().send(
|
||||
new ListObjectsV2Command({ Bucket: config.s3Bucket, MaxKeys: 1 }),
|
||||
);
|
||||
return { configured: true, ok: true, backend: "s3" };
|
||||
} catch (listErr) {
|
||||
try {
|
||||
await probeStorageReadWrite();
|
||||
return { configured: true, ok: true, backend: "s3" };
|
||||
} catch (writeErr) {
|
||||
const error = `list=${s3Err(listErr)}; write=${s3Err(writeErr)}`;
|
||||
console.error(
|
||||
"[storage] ping failed",
|
||||
error,
|
||||
"endpoint=",
|
||||
config.s3Endpoint,
|
||||
"bucket=",
|
||||
config.s3Bucket,
|
||||
"region=",
|
||||
config.s3Region,
|
||||
);
|
||||
return { configured: true, ok: false, backend: "s3", error };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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}`;
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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;
|
||||
}
|
||||
87
db/README.md
87
db/README.md
|
|
@ -1,81 +1,28 @@
|
|||
# Base de datos (Postgres + Liquibase)
|
||||
# Database migrations (Liquibase)
|
||||
|
||||
PANELS usa **Postgres** organizado como monolito modular: dos bases de datos
|
||||
físicamente separadas en el mismo servidor/instancia por ambiente.
|
||||
Homologa el schema de cada ambiente con la misma cadena de changesets.
|
||||
|
||||
| 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. |
|
||||
## Bases
|
||||
|
||||
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.
|
||||
| Archivo | Changelog |
|
||||
|---------|-----------|
|
||||
| `data/app.db` | `db/app/` |
|
||||
| `data/platform.db` | `db/platform/` |
|
||||
|
||||
## Aprovisionamiento (una vez por ambiente, antes de Liquibase)
|
||||
## Cómo aplicar
|
||||
|
||||
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`):
|
||||
Requisitos: Java 17+.
|
||||
|
||||
```bash
|
||||
# Local, después de correr db/provision/dev-local.sh:
|
||||
set -a && source .env.dev-local && set +a
|
||||
./db/update.sh all --context-filter=dev # dev: incluye datos de demo
|
||||
./db/update.sh all --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'
|
||||
# descarga CLI + JDBC (una vez) y aplica changesets
|
||||
./db/update.sh all
|
||||
# o solo una
|
||||
./db/update.sh app
|
||||
./db/update.sh platform
|
||||
```
|
||||
|
||||
**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.
|
||||
También: `npm run db:migrate` o al arrancar la API (`runLiquibase()` en Deno).
|
||||
|
||||
`npm run db:migrate` corre `./db/update.sh all`. **Las migraciones ya NO
|
||||
corren automáticamente al arrancar la API** -- son un paso explícito de
|
||||
deploy (a diferencia del `runLiquibase()` que existía con SQLite).
|
||||
Nuevos cambios de schema → nuevo changeset en `db/app/changesets/` o `db/platform/changesets/` y referenciarlo en el `changelog-master.xml` correspondiente. No añadir migraciones en `api/db.ts`.
|
||||
|
||||
Contexts de Liquibase:
|
||||
- Sin `context`: changesets de esquema y catálogos requeridos (`risk_levels`,
|
||||
`document_types`, etc.) -- corren en **todos** los ambientes.
|
||||
- `context="dev"`: datos de demostración (tenant/empresas/proyecto ARCTEC) --
|
||||
**nunca** en staging/producción. Siempre pasar `--context-filter`
|
||||
explícito; no confiar en el default de Liquibase.
|
||||
|
||||
## 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.
|
||||
Las herramientas viven en `db/tools/` (gitignored); `bootstrap-tools.sh` las descarga.
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
# 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.
|
||||
13
db/app/changelog-master.xml
Normal file
13
db/app/changelog-master.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?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>
|
||||
254
db/app/changesets/001-baseline.sql
Normal file
254
db/app/changesets/001-baseline.sql
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
--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);
|
||||
43
db/app/changesets/002-tenant-id.sql
Normal file
43
db/app/changesets/002-tenant-id.sql
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
--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);
|
||||
11
db/app/changesets/003-must-change-password.sql
Normal file
11
db/app/changesets/003-must-change-password.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
--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 '';
|
||||
40
db/app/changesets/004-document-catalog.sql
Normal file
40
db/app/changesets/004-document-catalog.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
--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'))
|
||||
);
|
||||
164
db/app/changesets/005-payroll-week.sql
Normal file
164
db/app/changesets/005-payroll-week.sql
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
--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;
|
||||
4
db/app/liquibase.properties
Normal file
4
db/app/liquibase.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
changeLogFile=changelog-master.xml
|
||||
driver=org.sqlite.JDBC
|
||||
url=jdbc:sqlite:../../data/app.db
|
||||
classpath=../tools/sqlite-jdbc.jar
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# 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).
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -13,10 +13,10 @@ if [[ ! -x "$TOOLS/liquibase/liquibase" ]]; then
|
|||
rm -f liquibase.zip
|
||||
fi
|
||||
|
||||
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"
|
||||
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"
|
||||
fi
|
||||
|
||||
echo "Liquibase tools ready."
|
||||
|
|
|
|||
|
|
@ -1,16 +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
|
||||
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>
|
||||
|
|
@ -1,482 +0,0 @@
|
|||
--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
|
||||
);
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
--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()
|
||||
);
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
--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;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
--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');
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
--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);
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,12 +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
|
||||
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>
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
--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.
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
--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);
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
<?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_platform: control plane SaaS. Un solo esquema (public). -->
|
||||
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
|
||||
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-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>
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
--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));
|
||||
20
db/platform/changesets/001-init.sql
Normal file
20
db/platform/changesets/001-init.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
--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'))
|
||||
);
|
||||
7
db/platform/changesets/002-seed-arctec-tenant.sql
Normal file
7
db/platform/changesets/002-seed-arctec-tenant.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
--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');
|
||||
16
db/platform/changesets/003-tenant-license.sql
Normal file
16
db/platform/changesets/003-tenant-license.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
--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 '';
|
||||
30
db/platform/changesets/004-tenant-status-expand.sql
Normal file
30
db/platform/changesets/004-tenant-status-expand.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
--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;
|
||||
16
db/platform/changesets/005-contact-access.sql
Normal file
16
db/platform/changesets/005-contact-access.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
--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;
|
||||
7
db/platform/changesets/006-tenant-code-date.sql
Normal file
7
db/platform/changesets/006-tenant-code-date.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
--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';
|
||||
18
db/platform/changesets/007-smtp-settings.sql
Normal file
18
db/platform/changesets/007-smtp-settings.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
--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);
|
||||
|
|
@ -1,8 +1,4 @@
|
|||
# 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
|
||||
changeLogFile=changelog-master.xml
|
||||
driver=org.sqlite.JDBC
|
||||
url=jdbc:sqlite:../../data/platform.db
|
||||
classpath=../tools/sqlite-jdbc.jar
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
-- 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';
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
#!/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)."
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
#!/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")"
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
#!/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."
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
#!/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 --allow-sys 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
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
#!/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
|
||||
115
db/update.sh
115
db/update.sh
|
|
@ -1,127 +1,38 @@
|
|||
#!/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
|
||||
# (también se acepta postgres://). El host NO puede ser un CONTAINER ID.
|
||||
#
|
||||
# Uso:
|
||||
# set -a && source .env.dev-local && set +a && ./db/update.sh all
|
||||
# ./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"
|
||||
PG_JDBC="$ROOT/db/tools/postgresql-jdbc.jar"
|
||||
SQLITE_JDBC="$ROOT/db/tools/sqlite-jdbc.jar"
|
||||
|
||||
mkdir -p "$ROOT/data"
|
||||
|
||||
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"
|
||||
url="${url#"${url%%[![:space:]]*}"}"
|
||||
url="${url%"${url##*[![:space:]]}"}"
|
||||
url="${url#\'}"; url="${url%\'}"
|
||||
url="${url#\"}"; url="${url%\"}"
|
||||
if [[ "$url" == postgres://* ]]; then
|
||||
url="postgresql://${url#postgres://}"
|
||||
fi
|
||||
if [[ "$url" != postgresql://* ]]; then
|
||||
echo "ERROR: la URL debe empezar por postgresql:// o postgres://" >&2
|
||||
exit 1
|
||||
fi
|
||||
local rest="${url#postgresql://}"
|
||||
local userinfo="${rest%%@*}"
|
||||
local hostpart="${rest#*@}"
|
||||
if [[ "$userinfo" == "$rest" || -z "$hostpart" ]]; then
|
||||
echo "ERROR: URL sin host (falta user:pass@host)" >&2
|
||||
exit 1
|
||||
fi
|
||||
PARSED_USER="${userinfo%%:*}"
|
||||
PARSED_PASS="${userinfo#*:}"
|
||||
PARSED_JDBC="jdbc:postgresql://${hostpart}"
|
||||
local hp="${hostpart%%\?*}"
|
||||
hp="${hp%%/*}"
|
||||
PARSED_HOST="${hp%%:*}"
|
||||
}
|
||||
|
||||
preflight_host() {
|
||||
echo "==> host JDBC: ${PARSED_HOST}"
|
||||
if [[ "$PARSED_HOST" =~ ^[0-9a-f]{12}$ ]]; then
|
||||
echo "ERROR: '${PARSED_HOST}' es un CONTAINER ID de Docker, no un hostname." >&2
|
||||
echo "En Coolify copia Postgres URL (internal): el texto entre @ y :5432." >&2
|
||||
echo "Pon ese hostname en las 6 DATABASE_URL_* (mismo host) y conecta el" >&2
|
||||
echo "compose a la red predefined 'coolify'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$PARSED_HOST" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
return 0
|
||||
fi
|
||||
if command -v getent >/dev/null 2>&1; then
|
||||
if ! getent hosts "$PARSED_HOST" >/dev/null 2>&1; then
|
||||
echo "ERROR: no se resuelve el host '${PARSED_HOST}' (DNS)." >&2
|
||||
echo "Usa el hostname de Postgres URL (internal), no el ID del contenedor." >&2
|
||||
echo "El stack PANELS debe estar en la misma red Docker que Postgres (coolify)." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
run_one() {
|
||||
local name="$1"
|
||||
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)"
|
||||
preflight_host
|
||||
local dir="$ROOT/db/$name"
|
||||
local db="$ROOT/data/${name}.db"
|
||||
echo "==> Liquibase update: $name ($db)"
|
||||
(
|
||||
cd "$ROOT/db/$name"
|
||||
cd "$dir"
|
||||
"$LIQUIBASE" \
|
||||
--defaultsFile=liquibase.properties \
|
||||
--classpath="$PG_JDBC" \
|
||||
--url="$PARSED_JDBC" \
|
||||
--username="$PARSED_USER" \
|
||||
--password="$PARSED_PASS" \
|
||||
"$LB_COMMAND" "${EXTRA_ARGS[@]}"
|
||||
--classpath="$SQLITE_JDBC" \
|
||||
--url="jdbc:sqlite:$db" \
|
||||
update
|
||||
)
|
||||
}
|
||||
|
||||
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|platform|iam|core] [-- liquibase-args...]" >&2
|
||||
echo "Usage: $0 [all|app|platform]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
|
|||
|
|
@ -1,43 +1,17 @@
|
|||
# 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.
|
||||
#
|
||||
# Coolify: un solo archivo — docker-compose.yml. No añadir overlays.
|
||||
# Local: docker compose -f docker-compose.yml -f overlays/compose.local.yml up --build
|
||||
#
|
||||
# `data` = red donde viven Postgres/Redis de Coolify (UUID tipo
|
||||
# qzegekm3sr2bevgxl4wh4th2). Sin esta red, Liquibase da UnknownHostException.
|
||||
# Coolify / local — 3 services, SQLite en volumen panel-data
|
||||
services:
|
||||
migrate:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.migrate
|
||||
restart: "no"
|
||||
networks:
|
||||
- default
|
||||
- data
|
||||
environment:
|
||||
DATABASE_URL_PLATFORM_OWNER: ${DATABASE_URL_PLATFORM_OWNER:?}
|
||||
DATABASE_URL_IAM_OWNER: ${DATABASE_URL_IAM_OWNER:?}
|
||||
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:-}
|
||||
|
|
@ -46,22 +20,6 @@ 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:-}
|
||||
networks:
|
||||
- default
|
||||
- data
|
||||
volumes:
|
||||
- panel-data:/app/data
|
||||
expose:
|
||||
|
|
@ -77,7 +35,7 @@ services:
|
|||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
start_period: 45s
|
||||
|
||||
web-panel:
|
||||
build:
|
||||
|
|
@ -107,8 +65,3 @@ services:
|
|||
|
||||
volumes:
|
||||
panel-data:
|
||||
|
||||
networks:
|
||||
data:
|
||||
name: ${PANELS_DOCKER_NETWORK:-coolify}
|
||||
external: true
|
||||
|
|
|
|||
134
docs/coolify.md
134
docs/coolify.md
|
|
@ -1,29 +1,24 @@
|
|||
# Deploy PANELS en Coolify (Docker Compose + Postgres + Redis)
|
||||
# Deploy PANELS en Coolify (Docker Compose)
|
||||
|
||||
## Resumen
|
||||
|
||||
| Qué | Cuánto |
|
||||
|-----|--------|
|
||||
| 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) |
|
||||
| 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` |
|
||||
|
||||
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/` (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 |
|
||||
| `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 |
|
||||
|
||||
## Variables de entorno (servicio `api`)
|
||||
|
||||
|
|
@ -31,24 +26,15 @@ distintos dentro de `panels_product`, y las reglas del monolito modular.
|
|||
|
||||
| Variable | Formato | Uso |
|
||||
|----------|---------|-----|
|
||||
| `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` |
|
||||
| `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) |
|
||||
| `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 secretos:
|
||||
Generar:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32 # SESSION_SECRET o DOCS_KEY
|
||||
openssl rand -hex 24 # passwords de roles Postgres/Redis
|
||||
```
|
||||
|
||||
### Recomendadas (HTTPS / Coolify)
|
||||
|
|
@ -63,104 +49,50 @@ openssl rand -hex 24 # passwords de roles Postgres/Redis
|
|||
|
||||
| Variable | Uso |
|
||||
|----------|-----|
|
||||
| `API_KEY` | Auth alternativa por header `X-API-Key` **+ `X-Tenant-Id` obligatorio** (ya no ve todos los tenants, ver revisión de seguridad) |
|
||||
| `API_KEY` | Auth alternativa por header `X-API-Key` (vacío = desactivado) |
|
||||
| `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, 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**.
|
||||
- **api:** Deno 2.9 + OpenJDK 21 JRE + Liquibase (Debian; no Alpine por FFI SQLite)
|
||||
- **web-panel / web-saas:** build Node Alpine → **nginx Alpine**
|
||||
|
||||
Archivos: `Dockerfile.api`, `Dockerfile.migrate`, `Dockerfile.provision`, `Dockerfile.web-panel`, `Dockerfile.web-saas`, `docker-compose.yml`, `web-panel/nginx.conf`, `web-saas/nginx.conf`.
|
||||
Archivos: `Dockerfile.api`, `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. **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** → **solo** `docker-compose.yml` (Compose Location). **No** añadas `overlays/compose.local.yml` ni ningún `docker-compose.local.yml`. El overlay local pide `PLATFORM_*_PASSWORD` etc.; en Coolify esas 8 variables **no** se rellenan: los accesos van en las `DATABASE_URL_*` / `REDIS_URL_*` ya expandidas. Este archivo **no** levanta Postgres/Redis; usa los recursos que ya creaste. Conecta el stack a la **misma red** que Postgres y Redis (Connect to Predefined Network).
|
||||
4. Cargar en el recurso compose **todas** las variables obligatorias (incluidas las 3 `DATABASE_URL_*_OWNER`: las usa el job `migrate`). `DENO_ENV=production`.
|
||||
5. **Deploy.** En cada deploy corre `migrate` (`--context-filter=!dev`, sin demo) y **después** arranca `api`. No hace falta entrar al VPS a correr Liquibase.
|
||||
6. **Bootstrap del primer admin** (solo la primera vez): `bootstrap-admin.ts` (ver `db/README.md`).
|
||||
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_*`.
|
||||
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_*`.
|
||||
|
||||
## Troubleshooting: `UnknownHostException` / Liquibase no conecta
|
||||
|
||||
El host de las 6 `DATABASE_URL_*` y de las 2 `REDIS_URL_*` **no** es el ID corto del contenedor (`docker exec -it c72dde7d6b47 …`). Docker DNS en Coolify **no** resuelve ese ID; Liquibase falla con `UnknownHostException`.
|
||||
|
||||
Usa el hostname de **Postgres URL (internal)** / **Redis URL (internal)** en Coolify: el segmento entre `@` y `:5432` (o `:6379`). Un UUID tipo `qzegekm3sr2bevgxl4wh4th2` **es el host correcto**; un CONTAINER ID de 12 caracteres (`c72dde7d6b47`) no.
|
||||
|
||||
Ese UUID solo resuelve en la red Docker **`coolify`**. `docker-compose.yml` une `migrate` y `api` a esa red (`networks.data`). En Coolify deja también **Connect to Predefined Network**. Redis tiene **otro** UUID (el de Redis URL internal), no el de Postgres.
|
||||
|
||||
Si tras unir la red sigue `UnknownHostException`, en el VPS:
|
||||
## Local
|
||||
|
||||
```bash
|
||||
sudo docker network inspect coolify | grep -E 'Name|Aliases|qzegekm3'
|
||||
cp .env.example .env # rellenar obligatorias
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Usa el alias que aparezca junto al contenedor de Postgres (a veces `postgres-<uuid>`).
|
||||
|
||||
El log de deploy de Coolify **no** incluye stdout de Liquibase. Si `service "migrate" didn't complete successfully: exit 1`, el error real está en:
|
||||
|
||||
```bash
|
||||
sudo docker logs migrate-<uuid-del-recurso>-<timestamp>
|
||||
```
|
||||
|
||||
Ejemplo: `sudo docker logs migrate-pdyrt8ccp804tfmutefau0k6-052025710094`. `UnknownHostException` con un UUID largo = `migrate` fuera de `coolify`, no un host mal copiado. `Liquibase OK` + exit 0 = bien.
|
||||
|
||||
El `docker stop … No such container` del helper de Coolify es ruido de limpieza, no la causa. El build de imágenes puede ser OK y el deploy igual falla en `migrate`.
|
||||
|
||||
### `dependency api failed to start` / `api is unhealthy`
|
||||
|
||||
Liquibase ya corrió si ves `migrate … Exited` y acto seguido `api … Starting`. La API **no llega a escuchar** si Redis, S3 o los roles `_app` fallan (fail-fast antes de `Deno.serve`). Coolify entonces marca unhealthy en 1–2 s.
|
||||
|
||||
```bash
|
||||
sudo docker logs api-<uuid>-<timestamp>
|
||||
```
|
||||
|
||||
Busca `[startup] FAIL …`. Causas típicas:
|
||||
|
||||
- **Redis:** `REDIS_URL_IAM` / `REDIS_URL_CORE` con el UUID de **Postgres**. Redis tiene el suyo (Redis URL internal).
|
||||
- **S3/R2:** endpoint `https://<ACCOUNT_ID>.r2.cloudflarestorage.com` (sin barra final, **sin** el nombre del bucket), `S3_REGION=auto`, token **Account API** con Object Read & Write al bucket. R2 a menudo responde 403 a HeadBucket; el ping de la API usa ListObjects.
|
||||
- **Postgres `_app`:** Liquibase usa `*_OWNER`; el runtime usa `DATABASE_URL_PLATFORM`, `DATABASE_URL_IAM`, `DATABASE_URL_CORE` (passwords distintos).
|
||||
|
||||
### Coolify pide `PLATFORM_OWNER_PASSWORD`, `IAM_APP_PASSWORD`, `*_REDIS_PASSWORD`…
|
||||
|
||||
Eso sale de **Reload Compose** mezclando el overlay de desarrollo. En producción **no las rellenes**. En el recurso: Compose file = `docker-compose.yml` únicamente. Borra esas 8 variables si Coolify las marcó Required. Siguen haciendo falta las URLs completas (`DATABASE_URL_*`, `REDIS_URL_*`).
|
||||
|
||||
## Local (todo en docker-compose, incluyendo Postgres/Redis propios)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f overlays/compose.local.yml up --build
|
||||
```
|
||||
|
||||
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).
|
||||
Sin `SESSION_SECRET` / `DOCS_KEY` / `SEED_PASSWORD` / `PANEL_LOGIN_URL`, compose **no arranca**.
|
||||
|
||||
## Checklist ops
|
||||
|
||||
- [ ] `.env` y `data/` fuera del git
|
||||
- [ ] 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)
|
||||
- [ ] Secrets distintos a los de desarrollo
|
||||
- [ ] Backup del volumen `panel-data` (incluye DBs + SMTP guardado)
|
||||
- [ ] `COOKIE_SECURE=true` en HTTPS
|
||||
- [ ] Healthcheck API OK (`/v1/health` con las 5 conexiones en `true`)
|
||||
- [ ] `--context-filter` explícito en cada corrida de Liquibase contra staging/producción
|
||||
- [ ] Healthcheck API OK
|
||||
|
||||
## Qué no hace falta
|
||||
|
||||
- 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)
|
||||
- Postgres/MySQL/MariaDB en Coolify
|
||||
- Contenedor monolítico Node+Deno+Java
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
# Desarrollo local: Postgres + Redis en compose, provision y seed `dev`.
|
||||
# NO usar en Coolify (vive fuera de docker-compose*.yml para que Reload no lo mezcle).
|
||||
# Uso: docker compose -f docker-compose.yml -f overlays/compose.local.yml up --build
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
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:
|
||||
|
||||
# El compose de Coolify exige la red externa `coolify`. En local no existe:
|
||||
# esta red puente sustituye `data` (Compose 2.24+ !override).
|
||||
networks:
|
||||
data: !override
|
||||
name: panels-local-data
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
"dev:web": "cd web-panel && npm run dev",
|
||||
"dev:saas": "cd web-saas && npm run dev",
|
||||
"db:migrate": "./db/update.sh all",
|
||||
"docker:build": "docker compose -f docker-compose.yml -f overlays/compose.local.yml build",
|
||||
"docker:up": "docker compose -f docker-compose.yml -f overlays/compose.local.yml up -d"
|
||||
"docker:build": "docker compose build",
|
||||
"docker:up": "docker compose up -d"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
129
scripts/migrate-tenant-codes.ts
Normal file
129
scripts/migrate-tenant-codes.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* 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();
|
||||
Loading…
Reference in a new issue