mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 14:03:16 +00:00
Fase 2 (driver): - api/pg.ts: adaptador delgado sobre postgres.js (prepare/get/all/run, placeholders ? -> $n, withTenant con set_config para RLS), con parsers de tipo custom (numeric/date/timestamp(tz)/bigint) para que el resto del codigo heredado de SQLite (fechas/montos como string, ids como number) siga funcionando sin reescribir cada call-site a mano. - api/platform_db.ts, api/iam_db.ts (nuevo), api/db.ts: pools separados por base/esquema (panels_platform, panels_product.iam, panels_product.core), owner pool para bootstrap/scripts/lookups administrativos que cruzan tenant a proposito. - api/redis.ts: clientes iam/core separados (ACL panels_iam_redis / panels_core_redis). - api/sessions.ts + auth.ts: sesiones ahora en Redis (cookie = id opaco, no HMAC autocontenido); revocacion real (logout, cambio de password). - api/storage.ts (Fase 4c): documentos/PDFs via Contabo Object Storage (S3), con fallback a disco local si no hay credenciales S3 (dev). - api/scope.ts: middleware withCoreScope/requireCoreAuth que abre la transaccion con app.tenant_id fijado (RLS) para cada request. - api/cache.ts (Fase 4e): cache Redis con tenant_id obligatorio en la llave; aplicado a /v1/catalogs. Fase 3 (reescritura SQL, ~80 endpoints en main.ts/companies.ts/budget.ts/ payroll.ts/payroll_http.ts/excel.ts/saas.ts/smtp.ts): - Todo async/await, sintaxis Postgres (COALESCE, ~ regex, ON CONFLICT, now()/current_date, booleanos reales, RETURNING via lastInsertId()). - IDOR cross-tenant cerrado: GET/PATCH /v1/projects/:id, /v1/workers/:id ya no dependen de que el handler recuerde el WHERE tenant_id -- Row Level Security lo hace estructuralmente (verificado con un segundo tenant real: 404 en vez de fuga de datos). - API key ya no ve todos los tenants: ahora exige X-Tenant-Id explicito. Fase 3b (tests): api/test_helpers.ts corre cada test en una transaccion que siempre se revierte, contra el mismo baseline de Liquibase que produccion (ya no un esquema SQLite escrito a mano). payroll_test.ts reescrito con fixtures reales; 11/11 pasan contra Postgres. Fase 4 (IAM/RBAC): iam.roles/permissions/role_permissions formalizados (ver db/iam ya en fase 1); uploaded_by/created_by ahora son snapshot desnormalizado (uploaded_by_id/name); seed() en runtime eliminado, reemplazado por scripts/bootstrap-admin.ts (one-shot). Fase 4d (zona horaria): nuevo endpoint /v1/configuracion (GET/PUT), PAYROLL_TZ hardcodeado reemplazado por tenant_settings.timezone, document_validity.ts ya no usa new Date() crudo. Verificado end-to-end contra Postgres+Redis reales: login, sesiones, catalogos con cache, alta de trabajador, subida/descarga de documento cifrado, y el fix de IDOR probado con un segundo tenant real (403/404 en vez de fuga de datos). Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
import type { Db } from "./db.ts";
|
|
|
|
export type Company = {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
parent_id: number | null;
|
|
kind: "principal" | "sub";
|
|
status: "activo" | "inactivo";
|
|
tenant_id?: number | null;
|
|
registro_patronal?: string;
|
|
razon_social?: string;
|
|
nombre_comercial?: string;
|
|
rfc?: string;
|
|
regimen_fiscal?: string;
|
|
clase_riesgo?: string;
|
|
domicilio_fiscal?: string;
|
|
codigo_postal?: string;
|
|
ciudad?: string;
|
|
estado?: string;
|
|
telefono?: string;
|
|
email?: string;
|
|
representante_legal?: string;
|
|
giro?: string;
|
|
created_at?: string;
|
|
parent_code?: string | null;
|
|
parent_name?: string | null;
|
|
worker_count?: number;
|
|
};
|
|
|
|
export type CompanyProfileInput = {
|
|
name?: string;
|
|
code?: string;
|
|
status?: string;
|
|
parent_id?: number | null;
|
|
registro_patronal?: string;
|
|
razon_social?: string;
|
|
nombre_comercial?: string;
|
|
rfc?: string;
|
|
regimen_fiscal?: string;
|
|
clase_riesgo?: string;
|
|
domicilio_fiscal?: string;
|
|
codigo_postal?: string;
|
|
ciudad?: string;
|
|
estado?: string;
|
|
telefono?: string;
|
|
email?: string;
|
|
representante_legal?: string;
|
|
giro?: string;
|
|
};
|
|
|
|
const CODE_RE = /^[A-Z0-9][A-Z0-9_-]{1,15}$/;
|
|
const RFC_RE = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/;
|
|
|
|
function trimText(value: unknown): string {
|
|
return (value ?? "").toString().trim();
|
|
}
|
|
|
|
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).
|
|
const where = tenantId != null ? "WHERE c.tenant_id = ?" : "";
|
|
const params = tenantId != null ? [tenantId] : [];
|
|
return await database.prepare(
|
|
`SELECT c.*, p.code AS parent_code, p.name AS parent_name,
|
|
(SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count
|
|
FROM companies c
|
|
LEFT JOIN companies p ON p.id = c.parent_id
|
|
${where}
|
|
ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, LOWER(c.name)`,
|
|
).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
|
|
| Company
|
|
| undefined;
|
|
}
|
|
|
|
export async function companyByCode(database: Db, code: string): Promise<Company | undefined> {
|
|
return await database.prepare("SELECT * FROM companies WHERE code = ?").get(
|
|
normCompanyCode(code),
|
|
) as Company | undefined;
|
|
}
|
|
|
|
export async function principalCompany(database: Db, tenantId?: number | null): Promise<Company | undefined> {
|
|
if (tenantId != null) {
|
|
return await 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(
|
|
"SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1",
|
|
).get() as Company | undefined;
|
|
}
|
|
|
|
export function normCompanyCode(value: string | null | undefined): string {
|
|
return (value ?? "").toString().trim().toUpperCase().replace(/\s+/g, "");
|
|
}
|
|
|
|
export function companyCodeFromName(name: string): string {
|
|
const slug = name
|
|
.normalize("NFD")
|
|
.replace(/\p{M}/gu, "")
|
|
.toUpperCase()
|
|
.replace(/[^A-Z0-9]+/g, "")
|
|
.slice(0, 12);
|
|
return slug || "EMP";
|
|
}
|
|
|
|
export async function nextCompanyCode(database: Db, name: string): Promise<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}'`,
|
|
).get() as { n: number };
|
|
return `EMP-${String(row.n).padStart(4, "0")}`;
|
|
}
|
|
|
|
export async function resolveCompany(
|
|
database: Db,
|
|
body: { company_id?: number | null; hire_type?: string | null },
|
|
): Promise<Company | undefined> {
|
|
if (body.company_id) {
|
|
const byId = await companyById(database, Number(body.company_id));
|
|
if (byId) return byId;
|
|
}
|
|
const code = normCompanyCode(body.hire_type);
|
|
if (code) return await companyByCode(database, code);
|
|
return undefined;
|
|
}
|
|
|
|
export function validateCompanyCode(code: string): string | null {
|
|
if (!CODE_RE.test(code)) {
|
|
return "Código de 2 a 16 caracteres (letras, números, _ o -)";
|
|
}
|
|
return 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)";
|
|
}
|
|
if (opts.requireLegal) {
|
|
if (!trimText(input.razon_social) && !trimText(input.name) && !trimText(input.nombre_comercial)) {
|
|
return "Indique razón social o nombre comercial";
|
|
}
|
|
}
|
|
const clase = trimText(input.clase_riesgo).toUpperCase();
|
|
if (clase && !["I", "II", "III", "IV", "V"].includes(clase)) {
|
|
return "Clase de riesgo IMSS debe ser I, II, III, IV o V";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function normalizeCompanyProfile(input: CompanyProfileInput, fallbackName = "") {
|
|
const nombreComercial = trimText(input.nombre_comercial) || trimText(input.name) || fallbackName;
|
|
const razonSocial = trimText(input.razon_social) || nombreComercial || fallbackName;
|
|
const name = trimText(input.name) || nombreComercial || razonSocial || fallbackName;
|
|
return {
|
|
name,
|
|
nombre_comercial: nombreComercial,
|
|
razon_social: razonSocial,
|
|
rfc: normRfc(input.rfc),
|
|
regimen_fiscal: trimText(input.regimen_fiscal),
|
|
registro_patronal: trimText(input.registro_patronal).toUpperCase(),
|
|
clase_riesgo: trimText(input.clase_riesgo).toUpperCase(),
|
|
domicilio_fiscal: trimText(input.domicilio_fiscal),
|
|
codigo_postal: trimText(input.codigo_postal),
|
|
ciudad: trimText(input.ciudad),
|
|
estado: trimText(input.estado),
|
|
telefono: trimText(input.telefono),
|
|
email: trimText(input.email).toLowerCase(),
|
|
representante_legal: trimText(input.representante_legal),
|
|
giro: trimText(input.giro),
|
|
};
|
|
}
|
|
|
|
function validateProfile(input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}): string | null {
|
|
return validateCompanyProfile(input, opts);
|
|
}
|
|
|
|
function profileFromInput(input: CompanyProfileInput, fallbackName = "") {
|
|
return normalizeCompanyProfile(input, fallbackName);
|
|
}
|
|
|
|
export async function createSubcompany(
|
|
database: Db,
|
|
input: CompanyProfileInput,
|
|
tenantId?: number | null,
|
|
): Promise<{ company?: Company; error?: string }> {
|
|
const profileErr = validateProfile(input, { requireLegal: true });
|
|
if (profileErr) return { error: profileErr };
|
|
const profile = profileFromInput(input);
|
|
if (!profile.name) return { error: "Nombre de empresa obligatorio" };
|
|
|
|
const principal = await principalCompany(database, tenantId);
|
|
if (!principal) return { error: "No hay empresa principal" };
|
|
const parentId = input.parent_id ? Number(input.parent_id) : principal.id;
|
|
const parent = await companyById(database, parentId);
|
|
if (!parent) return { error: "Empresa padre no encontrada" };
|
|
if (tenantId != null && parent.tenant_id != null && parent.tenant_id !== tenantId) {
|
|
return { error: "Empresa padre de otro tenant" };
|
|
}
|
|
|
|
let code = normCompanyCode(input.code);
|
|
if (!code) code = await 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" };
|
|
|
|
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()) };
|
|
}
|
|
|
|
export async function updateCompany(
|
|
database: Db,
|
|
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 };
|
|
}
|
|
|
|
const profileErr = validateProfile(input);
|
|
if (profileErr) return { error: profileErr, status: 400 };
|
|
|
|
const merged: CompanyProfileInput = {
|
|
name: input.name !== undefined ? input.name : current.name,
|
|
code: input.code !== undefined ? input.code : current.code,
|
|
status: input.status !== undefined ? input.status : current.status,
|
|
registro_patronal: input.registro_patronal !== undefined ? input.registro_patronal : current.registro_patronal,
|
|
razon_social: input.razon_social !== undefined ? input.razon_social : current.razon_social,
|
|
nombre_comercial: input.nombre_comercial !== undefined ? input.nombre_comercial : current.nombre_comercial,
|
|
rfc: input.rfc !== undefined ? input.rfc : current.rfc,
|
|
regimen_fiscal: input.regimen_fiscal !== undefined ? input.regimen_fiscal : current.regimen_fiscal,
|
|
clase_riesgo: input.clase_riesgo !== undefined ? input.clase_riesgo : current.clase_riesgo,
|
|
domicilio_fiscal: input.domicilio_fiscal !== undefined ? input.domicilio_fiscal : current.domicilio_fiscal,
|
|
codigo_postal: input.codigo_postal !== undefined ? input.codigo_postal : current.codigo_postal,
|
|
ciudad: input.ciudad !== undefined ? input.ciudad : current.ciudad,
|
|
estado: input.estado !== undefined ? input.estado : current.estado,
|
|
telefono: input.telefono !== undefined ? input.telefono : current.telefono,
|
|
email: input.email !== undefined ? input.email : current.email,
|
|
representante_legal: input.representante_legal !== undefined
|
|
? input.representante_legal
|
|
: current.representante_legal,
|
|
giro: input.giro !== undefined ? input.giro : current.giro,
|
|
};
|
|
const profile = profileFromInput(merged, current.name);
|
|
if (!profile.name) return { error: "Nombre de empresa obligatorio", status: 400 };
|
|
|
|
let code = current.code;
|
|
if (input.code !== undefined) {
|
|
code = normCompanyCode(input.code);
|
|
const codeErr = validateCompanyCode(code);
|
|
if (codeErr) return { error: codeErr, status: 400 };
|
|
const clash = await companyByCode(database, code);
|
|
if (clash && clash.id !== id) return { error: "Ya existe una empresa con ese código", status: 400 };
|
|
}
|
|
|
|
let status = current.status;
|
|
if (input.status !== undefined) {
|
|
if (!["activo", "inactivo"].includes(input.status)) {
|
|
return { error: "Estatus debe ser activo o inactivo", status: 400 };
|
|
}
|
|
if (current.kind === "principal" && input.status === "inactivo") {
|
|
return { error: "La empresa principal no se puede inactivar", status: 400 };
|
|
}
|
|
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;
|
|
}
|
|
if (code !== current.code) {
|
|
await database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id);
|
|
}
|
|
return { company: await companyById(database, id) };
|
|
}
|
|
|
|
/** Postgres error code 23505 = unique_violation. */
|
|
function isUniqueViolation(e: unknown): boolean {
|
|
return !!e && typeof e === "object" && (e as { code?: string }).code === "23505";
|
|
}
|