panels-origin/api/saas.ts

591 lines
20 KiB
TypeScript

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 { accessEmailAttachments, accessEmailBody, accessEmailHtml, 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<typeof normalizeCompanyProfile>,
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<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,
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<string, unknown> | 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: `Tu acceso a PANELS está listo — ${opts.companyName}`,
text: accessEmailBody({
companyName: opts.companyName,
username: opts.username,
password: opts.password,
contactName: opts.contactName,
}),
html: accessEmailHtml({
companyName: opts.companyName,
username: opts.username,
password: opts.password,
contactName: opts.contactName,
}),
attachments: accessEmailAttachments(),
});
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 };