mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 20:43:17 +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>
622 lines
22 KiB
TypeScript
622 lines
22 KiB
TypeScript
import { generateSecurePassword, hashPassword } from "./crypto.ts";
|
|
import { getCoreDb, type Db } from "./db.ts";
|
|
import { withIamOwner } from "./iam_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}`;
|
|
}
|
|
|
|
async function codeExists(platformDb: PlatformDb, coreDb: Db, code: string): Promise<boolean> {
|
|
return !!await tenantByCode(platformDb, code) || !!await companyByCode(coreDb, code);
|
|
}
|
|
|
|
/**
|
|
* Código único: prefijo del nombre comercial + YYMM de creación.
|
|
* Ej. «ARCTEC» en ago-2026 → ARCT2608. Si choca, sufijo numérico.
|
|
*/
|
|
export async function allocateUniqueTenantCode(
|
|
platformDb: PlatformDb,
|
|
coreDb: Db,
|
|
nombreComercial: string,
|
|
createdAt: Date = new Date(),
|
|
): Promise<string> {
|
|
const base = shortCodeFromCommercialName(nombreComercial);
|
|
const stamp = codeDateStamp(createdAt);
|
|
const primary = `${base}${stamp}`;
|
|
if (!await codeExists(platformDb, coreDb, 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 && !await codeExists(platformDb, coreDb, 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 async function listTenants(platformDb: PlatformDb) {
|
|
return await listTenantsRaw(platformDb);
|
|
}
|
|
|
|
export async function getTenantDetail(
|
|
platformDb: PlatformDb,
|
|
coreDb: Db,
|
|
tenantId: number,
|
|
): Promise<TenantDetail | null> {
|
|
const t = await 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;
|
|
|
|
// company/admins viven en core/iam -- este lookup administrativo cruza
|
|
// tenants a propósito (consola SaaS viendo el detalle de UN tenant desde
|
|
// fuera de cualquier sesión de ese tenant), así que coreDb debe ser la
|
|
// conexión owner (bypassa RLS), no una conexión scoped por tenant.
|
|
const company = await coreDb.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 = await withIamOwner(async (iam) => {
|
|
const rows = await iam.prepare(
|
|
`SELECT id, username, display_name, COALESCE(email, '') AS email, role_code AS role,
|
|
COALESCE(must_change_password, false) AS must_change_password, created_at
|
|
FROM users WHERE tenant_id = ? AND role_code = 'tenant_admin'
|
|
ORDER BY id`,
|
|
).all(tenantId) as Array<Record<string, unknown>>;
|
|
return rows.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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Alta de tenant: cruza panels_platform (tenants) + panels_product.core
|
|
* (companies) + panels_product.iam (users), tres bases/esquemas sin
|
|
* transacción distribuida posible entre ellos (Fase 5b del plan). Se
|
|
* implementa como compensación explícita: si algo falla después de crear
|
|
* el tenant y/o la empresa, se borra lo ya creado antes de devolver el
|
|
* error -- así un fallo a medio alta nunca deja un tenant sin empresa/admin
|
|
* o una empresa sin tenant. Es idempotente por `code`: si el caller
|
|
* reintenta tras un error ya limpiado, simplemente crea todo de nuevo.
|
|
*/
|
|
export async function createTenant(
|
|
platformDb: PlatformDb,
|
|
coreDb: Db,
|
|
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 = await allocateUniqueTenantCode(platformDb, coreDb, 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 = await withIamOwner((iam) => iam.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();
|
|
|
|
// Paso 1: tenant en panels_platform.
|
|
await 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 = await platformLastId(platformDb);
|
|
|
|
let companyId: number | undefined;
|
|
try {
|
|
// Paso 2: empresa principal en core (rol owner: alta administrativa
|
|
// cruzando tenants, no una operación de un usuario ya autenticado).
|
|
companyId = await getCoreDb().then((core) =>
|
|
core.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,
|
|
).then(() => core.lastInsertId())
|
|
);
|
|
|
|
// Paso 3: admin del tenant en iam.
|
|
const hash = await hashPassword(temporaryPassword);
|
|
await withIamOwner((iam) =>
|
|
iam.prepare(
|
|
`INSERT INTO users (
|
|
username, password_hash, display_name, company_id, tenant_id, role_code,
|
|
must_change_password, email
|
|
) VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, ?)`,
|
|
).run(adminUser, hash, adminName, companyId, tenantId, contactEmail)
|
|
);
|
|
} catch (e) {
|
|
// Compensación: limpiar en orden inverso lo que sí se alcanzó a crear.
|
|
if (companyId != null) {
|
|
await getCoreDb().then((core) => core.prepare("DELETE FROM companies WHERE id = ?").run(companyId));
|
|
}
|
|
await platformDb.prepare("DELETE FROM tenants WHERE id = ?").run(tenantId);
|
|
return { error: e instanceof Error ? e.message : "Error al crear empresa" };
|
|
}
|
|
|
|
const tenant = await 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 async function updateTenant(
|
|
platformDb: PlatformDb,
|
|
coreDb: Db,
|
|
tenantId: number,
|
|
input: UpdateTenantInput,
|
|
): Promise<{ error?: string }> {
|
|
const current = await tenantById(platformDb, tenantId);
|
|
if (!current) return { error: "Empresa no encontrada" };
|
|
|
|
const company = await coreDb.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 = await 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;
|
|
|
|
await 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) {
|
|
await coreDb.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,
|
|
coreDb: Db,
|
|
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 = await getTenantDetail(platformDb, coreDb, 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);
|
|
await withIamOwner((iam) =>
|
|
iam.prepare(
|
|
`UPDATE users SET password_hash = ?, must_change_password = true 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) {
|
|
await platformDb.prepare(
|
|
`UPDATE tenants SET access_sent_at = now() WHERE id = ?`,
|
|
).run(opts.tenantId);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function tenantAccessBlocked(platformDb: PlatformDb, tenantId: number | null): Promise<string | null> {
|
|
if (tenantId == null) return "Cuenta sin empresa asignada";
|
|
const t = await 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 };
|