panels-origin/api/db.ts
2026-08-25 20:21:49 -05:00

391 lines
14 KiB
TypeScript

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 { 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 = Database;
let db: Database | 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;
}
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,
);
}
}
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);
}
}
export function seedCompanyDocumentTypes(database: Database) {
const upsert = database.prepare(
`INSERT INTO company_document_types (code, label, required, category)
VALUES (?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
label = excluded.label,
required = excluded.required,
category = excluded.category`,
);
for (const d of COMPANY_DOCUMENT_TYPE_SEED) {
upsert.run(d.code, d.label, d.required, d.category);
}
}
export 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;
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
export const PROJECT_STATUS_CATALOG = [
{ code: "activo", label: "Activo" },
{ code: "pausado", label: "Pausado" },
{ code: "concluido", label: "Concluido" },
{ code: "cancelado", label: "Cancelado" },
] as const;
export function isProjectStatus(value: string): value is ProjectStatus {
return (PROJECT_STATUSES as readonly string[]).includes(value);
}
export function projectById(database: Database, id: number) {
return database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as
| { id: number; code: string; name: string; status: string }
| undefined;
}
export function projectMustBe(
project: { status: string } | undefined,
allowed: readonly ProjectStatus[],
closedMessage: string,
): { error: string; status: 400 | 404 } | null {
if (!project) return { error: "Proyecto no encontrado", status: 404 };
if (!allowed.includes(project.status as ProjectStatus)) {
return { error: closedMessage, status: 400 };
}
return null;
}
async function seed(database: Database) {
const risks = [
["rojo", "Rojo", "#A20000", "#FFFFFF"],
["amarillo", "Amarillo", "#EEEE3C", "#000000"],
["azul", "Azul", "#001485", "#FFFFFF"],
["verde", "Verde", "#008514", "#FFFFFF"],
["negro", "Negro", "#000000", "#FFFFFF"],
["naranja", "Naranja", "#FF5733", "#FFFFFF"],
];
const insRisk = database.prepare(
"INSERT OR IGNORE INTO risk_levels (code, label, color, text_color) VALUES (?, ?, ?, ?)",
);
for (const r of risks) insRisk.run(...r);
database.prepare(
"INSERT OR IGNORE INTO badge_themes (id, name, layout) VALUES (?, ?, ?)",
).run(
"arctec-dos-logos-fold",
"Arctec dos logos (doblez carta)",
"letter-landscape-4-fold",
);
seedDocumentTypes(database);
seedProjectDocumentTypes(database);
seedCompanyDocumentTypes(database);
const defaultTenantId = 1;
let principal = database.prepare(
"SELECT id FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1",
).get(defaultTenantId) as { id: number } | undefined;
if (!principal) {
database.prepare(
`INSERT INTO companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social)
VALUES ('ARCT2608', 'ARCTEC', NULL, 'principal', ?, 'ARCTEC', 'ARCTEC')`,
).run(defaultTenantId);
principal = database.prepare(
"SELECT id FROM companies WHERE code = 'ARCT2608'",
).get() as { id: number };
}
for (const [code, name] of [["FISICA", "FISICA"], ["ARCOTEC", "ARCOTEC"]] as const) {
const exists = database.prepare("SELECT id FROM companies WHERE code = ?").get(code);
if (!exists) {
database.prepare(
`INSERT INTO companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social)
VALUES (?, ?, ?, 'sub', ?, ?, ?)`,
).run(code, name, principal.id, defaultTenantId, name, name);
}
}
const users = [
["arct2608", "Administrador"],
];
// Migración suave de usuarios legacy → código puro
database.prepare(
"UPDATE users SET username = 'arct2608' WHERE username IN ('papa', 'adm.arctec') AND NOT EXISTS (SELECT 1 FROM users WHERE username = 'arct2608')",
).run();
for (const [username, display] of users) {
const exists = database.prepare("SELECT id FROM users WHERE username = ?").get(username);
if (!exists) {
const hash = await hashPassword(config.seedPassword);
database.prepare(
`INSERT INTO users (username, password_hash, display_name, company_id, tenant_id, role)
VALUES (?, ?, ?, ?, ?, 'tenant_admin')`,
).run(username, hash, display, principal.id, defaultTenantId);
}
}
database.prepare(
"UPDATE users SET company_id = ?, tenant_id = COALESCE(tenant_id, ?), role = COALESCE(NULLIF(role, ''), 'tenant_admin') WHERE company_id IS NULL OR tenant_id IS NULL",
).run(principal.id, defaultTenantId);
const proj = database.prepare("SELECT id FROM projects LIMIT 1").get();
if (!proj) {
database.prepare(
`INSERT INTO projects (code, name, address, theme_id, company_id, tenant_id)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
nextProjectCode(database),
"ZENDALA CANCUN",
"",
"arctec-dos-logos-fold",
principal.id,
defaultTenantId,
);
}
}
export function workerImssContext(database: Database, workerId: number): WorkerImssContext {
const w = database.prepare(
"SELECT imss_status, imss_alta_at, last_rehire_at FROM workers WHERE id = ?",
).get(workerId) as {
imss_status: ImssStatus;
imss_alta_at: string | null;
last_rehire_at: string | null;
} | undefined;
const altaDoc = database.prepare(
`SELECT imss_alta_at, uploaded_at FROM documents
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 {
imss_status: (w?.imss_status as ImssStatus) || "sin_alta",
last_rehire_at: w?.last_rehire_at ?? null,
current_alta_at: altaDoc?.imss_alta_at || w?.imss_alta_at || altaDoc?.uploaded_at || null,
};
}
/** 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: number;
validity_mode: string;
freshness_days: number | null;
requires_issued_at: number;
requires_expires_at: number;
category: string;
}[];
const currentDocs = database.prepare(
`SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at
FROM documents WHERE worker_id = ? AND is_current = 1`,
).all(workerId) as {
type_code: string;
issued_at: string | null;
expires_at: string | null;
uploaded_at: string;
imss_alta_at: string | null;
}[];
const byType = new Map(currentDocs.map((d) => [d.type_code, d]));
const ctx = workerImssContext(database, workerId);
return {
ctx,
freshness_required: freshnessRequired(ctx),
items: types.map((t) => {
const policy = {
code: t.code,
label: t.label,
required: !!t.required,
validity_mode: (t.validity_mode || "none") as "none" | "freshness" | "expiry",
freshness_days: t.freshness_days,
requires_issued_at: !!t.requires_issued_at,
requires_expires_at: !!t.requires_expires_at,
};
const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx);
return {
code: t.code,
label: t.label,
required: !!t.required,
validity_mode: policy.validity_mode,
freshness_days: t.freshness_days,
requires_issued_at: policy.requires_issued_at,
requires_expires_at: policy.requires_expires_at,
category: t.category || "otro",
present: evaled.present,
valid: evaled.valid,
validity_status: evaled.validity_status,
issued_at: evaled.issued_at,
expires_at: evaled.expires_at,
};
}),
};
}
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 = !!database.prepare(
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1",
).get(workerId);
const hasImss = ctx.imss_status === "alta";
return {
imss_status: ctx.imss_status,
freshness_required: needFresh,
imss_ready: requiredOk && !hasImss,
in_project_without_imss: inProject && !hasImss,
expediente_ok: requiredOk,
};
}
export function checklistFor(database: Database, workerId: number) {
return checklistItemsFor(database, workerId).items;
}
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") {
database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId);
return;
}
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 = database.prepare(
`SELECT 1 FROM badge_job_people p
JOIN badge_jobs j ON j.id = p.job_id
WHERE p.worker_id = ? LIMIT 1`,
).get(workerId);
let pipeline = "incompleto";
if (requiredOk && photoOk) pipeline = printed ? "impreso" : "listo_gafete";
const assigned = database.prepare(
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1",
).get(workerId);
if (pipeline !== "incompleto" && assigned) {
pipeline = printed ? "activo" : "listo_gafete";
}
database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId);
}
export function lastInsertId(database: Database): number {
const row = database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number };
return Number(row.id);
}
export function workerDir(workerId: number): string {
return join(EXPEDIENTES_DIR, String(workerId));
}
export function projectDir(projectId: number): string {
return join(PROJECTS_DIR, String(projectId));
}
export function companyDir(companyId: number): string {
return join(COMPANIES_DIR, String(companyId));
}
export function projectChecklistFor(database: Database, projectId: number) {
const types = database.prepare(
"SELECT code, label, required, category FROM project_document_types",
).all() as { code: string; label: string; required: number; category: string }[];
const current = database.prepare(
`SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = 1`,
).all(projectId) as { type_code: string }[];
const have = new Set(current.map((c) => c.type_code));
return types.map((t) => ({
code: t.code,
label: t.label,
required: !!t.required,
category: t.category || "otro",
present: have.has(t.code),
}));
}
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: 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) => ({
code: t.code,
label: t.label,
required: !!t.required,
category: t.category || "otro",
present: have.has(t.code),
}));
}