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 { 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), })); }