import type { Database } from "@db/sqlite"; import { generateSecurePassword, hashPassword } from "./crypto.ts"; import { lastInsertId as appLastId } from "./db.ts"; import { lastInsertId as platformLastId, listTenants as listTenantsRaw, tenantByCode, tenantById, type PlatformDb, } from "./platform_db.ts"; import { companyByCode, normCompanyCode, normalizeCompanyProfile, validateCompanyProfile, type CompanyProfileInput } from "./companies.ts"; import { accessEmailBody, sendMail } from "./mail.ts"; import { config } from "./config.ts"; export type CreateTenantInput = CompanyProfileInput & { admin_username?: string; admin_display_name?: string; contact_email?: string; contact_name?: string; plan?: string; valid_until?: string | null; notes?: string; send_email?: boolean; }; export type UpdateTenantInput = CompanyProfileInput & { status?: string; plan?: string; valid_until?: string | null; notes?: string; contact_email?: string; contact_name?: string; }; export type TenantAdmin = { id: number; username: string; display_name: string; email: string; role: string; must_change_password: boolean; created_at: string; }; export type CompanySnapshot = { id: number; code: string; name: string; rfc: string; razon_social: string; nombre_comercial: string; regimen_fiscal: string; registro_patronal: string; clase_riesgo: string; domicilio_fiscal: string; codigo_postal: string; ciudad: string; estado: string; telefono: string; email: string; representante_legal: string; giro: string; }; export type TenantDetail = { id: number; code: string; name: string; status: string; plan: string; valid_until: string | null; notes: string; contact_email: string; contact_name: string; access_sent_at: string | null; created_at: string; company: CompanySnapshot | null; admins: TenantAdmin[]; }; const STATUSES = ["activo", "suspendido", "cancelado"] as const; const PLANS = ["trial", "basic", "pro"] as const; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; /** Usuario admin = el código en minúsculas (corto). */ export function adminUsernameForCode(code: string): string { const c = normCompanyCode(code).toLowerCase(); return c || "emp"; } /** Prefijo corto (máx. 4) solo desde nombre comercial. */ export function shortCodeFromCommercialName(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); } /** YYMM de la fecha de alta (refuerzo corto del código). */ export function codeDateStamp(when: Date = new Date()): string { const yy = String(when.getFullYear()).slice(-2); const mm = String(when.getMonth() + 1).padStart(2, "0"); return `${yy}${mm}`; } 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 function allocateUniqueTenantCode( platformDb: PlatformDb, appDb: Database, nombreComercial: string, createdAt: Date = new Date(), ): string { const base = shortCodeFromCommercialName(nombreComercial); const stamp = codeDateStamp(createdAt); const primary = `${base}${stamp}`; 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 && !codeExists(platformDb, appDb, candidate)) return candidate; } return `E${stamp}${Date.now().toString(36).toUpperCase().slice(-4)}`; } function trim(value: unknown): string { return (value ?? "").toString().trim(); } function isoDateOrNull(value: unknown): string | null { const raw = trim(value); if (!raw) return null; const m = raw.match(/^(\d{4}-\d{2}-\d{2})/); return m ? m[1] : null; } /** Perfil completo obligatorio en alta/edición SaaS. */ export function requireSaasCompanyFields( profile: ReturnType, extra: { contact_name?: string; contact_email?: string } = {}, ): string | null { const checks: [string, string][] = [ ["Nombre comercial", profile.nombre_comercial], ["Razón social", profile.razon_social], ["RFC", profile.rfc], ["Régimen fiscal", profile.regimen_fiscal], ["Domicilio fiscal", profile.domicilio_fiscal], ["Código postal", profile.codigo_postal], ["Ciudad", profile.ciudad], ["Estado", profile.estado], ["Teléfono", profile.telefono], ["Email", profile.email], ["Representante legal", profile.representante_legal], ]; for (const [label, value] of checks) { if (!value) return `${label} obligatorio`; } if (!EMAIL_RE.test(profile.email)) return "Email inválido"; if (!/^\d{5}$/.test(profile.codigo_postal)) return "Código postal debe ser 5 dígitos"; const contactName = trim(extra.contact_name); if (!contactName) return "Nombre de contacto obligatorio"; const contactEmail = profile.email; if (!contactEmail || !EMAIL_RE.test(contactEmail)) { return "Email obligatorio (ahí se envía el acceso)"; } return null; } export function listTenants(platformDb: PlatformDb) { return listTenantsRaw(platformDb); } export function getTenantDetail( platformDb: PlatformDb, appDb: Database, tenantId: number, ): 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, COALESCE(contact_name, '') AS contact_name, access_sent_at FROM tenants WHERE id = ?`, ).get(tenantId) as | { id: number; code: string; name: string; status: string; created_at: string; plan: string; valid_until: string | null; notes: string; contact_email: string; contact_name: string; access_sent_at: string | null; } | undefined; if (!t) return null; const company = appDb.prepare( `SELECT id, code, name, COALESCE(rfc, '') AS rfc, COALESCE(razon_social, '') AS razon_social, COALESCE(nombre_comercial, '') AS nombre_comercial, COALESCE(regimen_fiscal, '') AS regimen_fiscal, COALESCE(registro_patronal, '') AS registro_patronal, COALESCE(clase_riesgo, '') AS clase_riesgo, COALESCE(domicilio_fiscal, '') AS domicilio_fiscal, COALESCE(codigo_postal, '') AS codigo_postal, COALESCE(ciudad, '') AS ciudad, COALESCE(estado, '') AS estado, COALESCE(telefono, '') AS telefono, COALESCE(email, '') AS email, COALESCE(representante_legal, '') AS representante_legal, COALESCE(giro, '') AS giro FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`, ).get(tenantId) as CompanySnapshot | undefined; const admins = (appDb.prepare( `SELECT id, username, display_name, COALESCE(email, '') AS email, role, COALESCE(must_change_password, 0) AS must_change_password, created_at FROM users WHERE tenant_id = ? AND role = 'tenant_admin' ORDER BY id`, ).all(tenantId) as Array>).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, company: company ?? null, admins, }; } export async function createTenant( platformDb: PlatformDb, appDb: Database, input: CreateTenantInput, ): Promise<{ tenant?: { id: number; code: string; name: string; status: string }; admin?: { username: string; temporary_password: string; email: string }; email?: { sent: boolean; error?: string }; error?: string; }> { const profileErr = validateCompanyProfile(input, { requireLegal: true }); if (profileErr) return { error: profileErr }; const profile = normalizeCompanyProfile(input); const completeErr = requireSaasCompanyFields(profile, { contact_name: input.contact_name, }); if (completeErr) return { error: completeErr }; const code = allocateUniqueTenantCode(platformDb, appDb, profile.nombre_comercial); const displayName = profile.nombre_comercial; const adminUser = adminUsernameForCode(code); const contactName = trim(input.contact_name); const contactEmail = profile.email; const adminName = trim(input.admin_display_name) || contactName; 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"; if (!(PLANS as readonly string[]).includes(plan)) return { error: "Plan inválido" }; const validUntil = isoDateOrNull(input.valid_until); const notes = trim(input.notes); const temporaryPassword = generateSecurePassword(); platformDb.prepare( `INSERT INTO tenants (code, name, status, plan, valid_until, notes, contact_email, contact_name) VALUES (?, ?, 'activo', ?, ?, ?, ?, ?)`, ).run(code, displayName, plan, validUntil, notes, contactEmail, contactName); const tenantId = platformLastId(platformDb); try { 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); const hash = await hashPassword(temporaryPassword); 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) { platformDb.prepare("DELETE FROM tenants WHERE id = ?").run(tenantId); return { error: e instanceof Error ? e.message : "Error al crear empresa" }; } const tenant = platformDb.prepare( "SELECT id, code, name, status FROM tenants WHERE id = ?", ).get(tenantId) as { id: number; code: string; name: string; status: string }; let emailResult: { sent: boolean; error?: string } = { sent: false }; if (input.send_email && contactEmail) { emailResult = await sendAccessEmail(platformDb, { tenantId, companyName: displayName, username: adminUser, password: temporaryPassword, contactEmail, contactName, }); } else if (input.send_email && !contactEmail) { emailResult = { sent: false, error: "Indique correo de contacto para enviar el acceso" }; } return { tenant, admin: { username: adminUser, temporary_password: temporaryPassword, email: contactEmail }, email: emailResult, }; } export function updateTenant( platformDb: PlatformDb, appDb: Database, tenantId: number, input: UpdateTenantInput, ): { error?: string } { const current = tenantById(platformDb, tenantId); if (!current) return { error: "Empresa no encontrada" }; const company = appDb.prepare( `SELECT * FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`, ).get(tenantId) as Record | undefined; const profileErr = validateCompanyProfile({ ...company, ...input, name: input.nombre_comercial ?? input.name ?? (company?.name as string), }, { requireLegal: true }); if (profileErr) return { error: profileErr }; const merged: CompanyProfileInput = { name: input.name ?? input.nombre_comercial ?? (company?.name as string) ?? current.name, nombre_comercial: input.nombre_comercial ?? (company?.nombre_comercial as string) ?? "", razon_social: input.razon_social ?? (company?.razon_social as string) ?? "", rfc: input.rfc ?? (company?.rfc as string) ?? "", regimen_fiscal: input.regimen_fiscal ?? (company?.regimen_fiscal as string) ?? "", registro_patronal: input.registro_patronal ?? (company?.registro_patronal as string) ?? "", clase_riesgo: input.clase_riesgo ?? (company?.clase_riesgo as string) ?? "", domicilio_fiscal: input.domicilio_fiscal ?? (company?.domicilio_fiscal as string) ?? "", codigo_postal: input.codigo_postal ?? (company?.codigo_postal as string) ?? "", ciudad: input.ciudad ?? (company?.ciudad as string) ?? "", estado: input.estado ?? (company?.estado as string) ?? "", telefono: input.telefono ?? (company?.telefono as string) ?? "", email: input.email ?? (company?.email as string) ?? "", representante_legal: input.representante_legal ?? (company?.representante_legal as string) ?? "", giro: input.giro ?? (company?.giro as string) ?? "", }; const profile = normalizeCompanyProfile(merged, current.name); 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 = ?`, ).get(tenantId) as { plan: string; valid_until: string | null; notes: string; contact_email: string; contact_name: string; }; const contactName = input.contact_name !== undefined ? trim(input.contact_name) : row.contact_name; const contactEmail = profile.email; const completeErr = requireSaasCompanyFields(profile, { contact_name: contactName, }); if (completeErr) return { error: completeErr }; let status = current.status; if (input.status !== undefined) { if (!(STATUSES as readonly string[]).includes(input.status)) { return { error: "Estatus inválido (activo, suspendido, cancelado)" }; } status = input.status; } if (!status) return { error: "Estatus obligatorio" }; let plan = row.plan; if (input.plan !== undefined) { const p = trim(input.plan); if (!(PLANS as readonly string[]).includes(p)) return { error: "Plan inválido" }; plan = p; } if (!plan) return { error: "Plan obligatorio" }; let validUntil = row.valid_until; if (input.valid_until !== undefined) validUntil = isoDateOrNull(input.valid_until); if (!validUntil) return { error: "Vigencia obligatoria" }; let notes = row.notes; if (input.notes !== undefined) notes = trim(input.notes); if (!notes) return { error: "Notas internas obligatorias" }; if (!contactEmail || !EMAIL_RE.test(contactEmail)) { return { error: "Email inválido" }; } if (!contactName) return { error: "Nombre de contacto obligatorio" }; const displayName = profile.nombre_comercial || profile.razon_social || current.name; platformDb.prepare( `UPDATE tenants SET name = ?, status = ?, plan = ?, valid_until = ?, notes = ?, contact_email = ?, contact_name = ? WHERE id = ?`, ).run(displayName, status, plan, validUntil, notes, contactEmail, contactName, tenantId); if (company?.id) { appDb.prepare( `UPDATE companies SET name = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, registro_patronal = ?, clase_riesgo = ?, domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?, representante_legal = ?, giro = ? WHERE id = ?`, ).run( displayName, 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, profile.email || contactEmail, profile.representante_legal, profile.giro, company.id, ); } return {}; } /** Regenera contraseña del admin, marca must_change_password y opcionalmente envía correo. */ export async function issueTenantAdminAccess( platformDb: PlatformDb, appDb: Database, tenantId: number, opts: { userId?: number; send_email?: boolean } = {}, ): Promise<{ admin?: { username: string; temporary_password: string; email: string }; email?: { sent: boolean; error?: string }; error?: string; }> { 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) : detail.admins[0]; if (!admin) return { error: "No hay usuario administrador" }; const temporaryPassword = generateSecurePassword(); const hash = await hashPassword(temporaryPassword); appDb.prepare( `UPDATE users SET password_hash = ?, must_change_password = 1 WHERE id = ? AND tenant_id = ?`, ).run(hash, admin.id, tenantId); const contactEmail = detail.contact_email || admin.email; let emailResult: { sent: boolean; error?: string } = { sent: false }; if (opts.send_email) { if (!contactEmail) { emailResult = { sent: false, error: "Falta correo de contacto en la empresa" }; } else { emailResult = await sendAccessEmail(platformDb, { tenantId, companyName: detail.name, username: admin.username, password: temporaryPassword, contactEmail, contactName: detail.contact_name || admin.display_name, }); } } return { admin: { username: admin.username, temporary_password: temporaryPassword, email: contactEmail, }, email: emailResult, }; } async function sendAccessEmail( platformDb: PlatformDb, opts: { tenantId: number; companyName: string; username: string; password: string; contactEmail: string; contactName: string; }, ): Promise<{ sent: boolean; error?: string }> { const result = await sendMail(platformDb, { to: opts.contactEmail, subject: `Acceso a PANELS — ${opts.companyName}`, text: accessEmailBody({ companyName: opts.companyName, username: opts.username, password: opts.password, contactName: opts.contactName, }), }); if (result.sent) { platformDb.prepare( `UPDATE tenants SET access_sent_at = datetime('now') WHERE id = ?`, ).run(opts.tenantId); } return result; } export function tenantAccessBlocked(platformDb: PlatformDb, tenantId: number | null): string | null { if (tenantId == null) return "Cuenta sin empresa asignada"; 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"; if (t.status === "suspendido") return "La suscripción de esta empresa está suspendida"; if (t.status === "cancelado") return "La cuenta de esta empresa fue cancelada"; if (t.status !== "activo") return "La empresa no está activa"; if (t.valid_until) { const today = new Date().toISOString().slice(0, 10); if (t.valid_until < today) return "La licencia de esta empresa ha vencido"; } return null; } export { config };