import { prisma } from '../config/database.js'; import bcrypt from 'bcryptjs'; import { randomBytes } from 'crypto'; import { getDespachoPlanLimits } from './plan-catalogo.service.js'; import { emailService } from './email/email.service.js'; import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared'; /** * Refactor F6.2 (multi-tenant): los users se listan/invitan/borran vía * `tenant_memberships`. `User.tenantId` y `User.rolId` se siguen poblando al * crear (legacy, F6.4 los borra) pero NO se leen en este servicio. * * - getUsuarios(tenantId) → memberships activos en ese tenant * - inviteUsuario → crea User + Membership (siempre isOwner=false aquí) * - updateUsuario → cambia role en la membership de ese tenant; active es global * - deleteUsuario → desactiva membership (soft delete por tenant). El user * sigue existiendo si tiene otras memberships. */ async function getRolId(nombre: string): Promise { const rol = await prisma.rol.findUnique({ where: { nombre } }); if (!rol) throw new Error(`Rol '${nombre}' no encontrado`); return rol.id; } /** Mapea una row de membership con user joineado al shape UserListItem. */ function mapMembershipRow(m: any, includeTenant = false): UserListItem { return { id: m.user.id, email: m.user.email, nombre: m.user.nombre, role: m.rol.nombre as Role, active: m.user.active && m.active, // user activo Y membership activa lastLogin: m.user.lastLogin?.toISOString() || null, createdAt: m.user.createdAt.toISOString(), ...(includeTenant && { tenantId: m.tenantId, tenantName: m.tenant.nombre }), }; } const MEMBERSHIP_INCLUDE = { user: { select: { id: true, email: true, nombre: true, active: true, lastLogin: true, createdAt: true, }, }, rol: { select: { nombre: true } }, }; export async function getUsuarios(tenantId: string): Promise { const memberships = await prisma.tenantMembership.findMany({ where: { tenantId, active: true }, include: MEMBERSHIP_INCLUDE, orderBy: { joinedAt: 'desc' }, }); return memberships.map(m => mapMembershipRow(m)); } export async function inviteUsuario(tenantId: string, data: UserInvite): Promise { const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { plan: true }, }); // Límite del catálogo despacho desde BD (con cache). -1 = ilimitado. // Si el plan no existe en BD (shouldn't happen post-seed) cae a 1 como // mínimo defensivo para no permitir invitaciones masivas accidentales. const planLimits = tenant ? await getDespachoPlanLimits(tenant.plan) : null; const maxUsers = planLimits?.maxUsers ?? 1; // Cuenta memberships activos del tenant (no users globales) — esto es el // límite real ahora que los users pueden estar en varios tenants. const currentCount = await prisma.tenantMembership.count({ where: { tenantId, active: true }, }); if (maxUsers !== -1 && currentCount >= maxUsers) { throw new Error('Límite de usuarios alcanzado para este plan'); } // Si el email ya existe como user global, agregamos membership en este // tenant en vez de crear un user nuevo. Cubre el caso "el contador X ya // trabaja en otra empresa, ahora lo invitan a esta también". let user = await prisma.user.findUnique({ where: { email: data.email } }); let tempPassword: string | null = null; if (!user) { tempPassword = randomBytes(4).toString('hex'); const passwordHash = await bcrypt.hash(tempPassword, 12); user = await prisma.user.create({ data: { email: data.email, passwordHash, nombre: data.nombre, lastTenantId: tenantId, }, }); // Enviar correo de bienvenida con credenciales (non-blocking) emailService.sendWelcome(data.email, { nombre: data.nombre, email: data.email, tempPassword, }).catch(err => console.error('[EMAIL] Welcome email failed:', err)); } const rolId = await getRolId(data.role); // Membership en este tenant. Si ya existía (caso edge: re-invitación tras // delete), reactivar. isOwner siempre false (owners se crean por register // o addTenantToOwner). await prisma.tenantMembership.upsert({ where: { userId_tenantId: { userId: user.id, tenantId } }, update: { rolId, isOwner: false, active: true }, create: { userId: user.id, tenantId, rolId, isOwner: false, active: true, }, }); // Volvemos a leer la membership para devolver el shape correcto const membership = await prisma.tenantMembership.findUnique({ where: { userId_tenantId: { userId: user.id, tenantId } }, include: MEMBERSHIP_INCLUDE, }); return mapMembershipRow(membership!); } export async function updateUsuario( tenantId: string, userId: string, data: UserUpdate ): Promise { // Verifica que la membership existe en este tenant antes de actualizar. const membership = await prisma.tenantMembership.findUnique({ where: { userId_tenantId: { userId, tenantId } }, }); if (!membership) { throw new Error('El usuario no es miembro de este tenant'); } // El cambio de role es por-tenant (afecta solo la membership) if (data.role) { const rolId = await getRolId(data.role); await prisma.tenantMembership.update({ where: { userId_tenantId: { userId, tenantId } }, data: { rolId }, }); } // active y nombre son globales del user const userUpdate: any = {}; if (data.nombre) userUpdate.nombre = data.nombre; if (data.active !== undefined) userUpdate.active = data.active; if (Object.keys(userUpdate).length > 0) { await prisma.user.update({ where: { id: userId }, data: userUpdate }); } const refreshed = await prisma.tenantMembership.findUnique({ where: { userId_tenantId: { userId, tenantId } }, include: MEMBERSHIP_INCLUDE, }); return mapMembershipRow(refreshed!); } export async function deleteUsuario(tenantId: string, userId: string): Promise { // Soft-delete: desactiva la membership. El user sigue existiendo (puede // tener acceso a otros tenants). Si era su única membership activa, queda // sin acceso pero no se borra el record. const result = await prisma.tenantMembership.deleteMany({ where: { userId, tenantId }, }); if (result.count === 0) { throw new Error('El usuario no es miembro de este tenant'); } } // ============================================================================ // Admin global (cross-tenant) // ============================================================================ /** * Lista todos los memberships del sistema. Cada row es una combinación * (user, tenant) — un mismo user con N memberships aparece N veces. La UI * admin global lo presenta así para ser explícita sobre quién tiene acceso * dónde. */ export async function getAllUsuarios(): Promise { const memberships = await prisma.tenantMembership.findMany({ where: { active: true }, include: { ...MEMBERSHIP_INCLUDE, tenant: { select: { id: true, nombre: true } }, }, orderBy: [{ tenant: { nombre: 'asc' } }, { joinedAt: 'desc' }], }); return memberships.map(m => mapMembershipRow(m, true)); } export async function createUsuarioGlobal( tenantId: string, data: UserInvite ): Promise { const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { plan: true }, }); // Límite del catálogo despacho desde BD (con cache). -1 = ilimitado. const planLimits = tenant ? await getDespachoPlanLimits(tenant.plan) : null; const maxUsers = planLimits?.maxUsers ?? 1; const currentCount = await prisma.tenantMembership.count({ where: { tenantId, active: true }, }); if (maxUsers !== -1 && currentCount >= maxUsers) { throw new Error('Límite de usuarios alcanzado para este plan'); } // Si el email ya existe como user global, agregamos membership en este tenant let user = await prisma.user.findUnique({ where: { email: data.email } }); let tempPassword: string | null = null; if (!user) { tempPassword = randomBytes(4).toString('hex'); const passwordHash = await bcrypt.hash(tempPassword, 12); user = await prisma.user.create({ data: { email: data.email, passwordHash, nombre: data.nombre, lastTenantId: tenantId, }, }); // Enviar correo de bienvenida con credenciales (non-blocking) emailService.sendWelcome(data.email, { nombre: data.nombre, email: data.email, tempPassword, }).catch(err => console.error('[EMAIL] Welcome email failed:', err)); } const rolId = await getRolId(data.role); await prisma.tenantMembership.upsert({ where: { userId_tenantId: { userId: user.id, tenantId } }, update: { rolId, isOwner: false, active: true }, create: { userId: user.id, tenantId, rolId, isOwner: false, active: true, }, }); const membership = await prisma.tenantMembership.findUnique({ where: { userId_tenantId: { userId: user.id, tenantId } }, include: MEMBERSHIP_INCLUDE, }); return mapMembershipRow(membership!); } export async function updateUsuarioGlobal( userId: string, data: UserUpdate & { tenantId?: string } ): Promise { // Si data.tenantId está presente: actualiza el role en esa membership. // Si data.active está: actualiza User.active globalmente. const targetTenantId = data.tenantId; if (data.role && targetTenantId) { const rolId = await getRolId(data.role); await prisma.tenantMembership.update({ where: { userId_tenantId: { userId, tenantId: targetTenantId } }, data: { rolId }, }); } const userUpdate: any = {}; if (data.nombre) userUpdate.nombre = data.nombre; if (data.active !== undefined) userUpdate.active = data.active; if (Object.keys(userUpdate).length > 0) { await prisma.user.update({ where: { id: userId }, data: userUpdate }); } // Devuelve la primera membership activa del user (o la del tenant target si // se especificó) para mantener el shape esperado por el caller. const where = targetTenantId ? { userId_tenantId: { userId, tenantId: targetTenantId } } : undefined; const m = where ? await prisma.tenantMembership.findUnique({ where, include: { ...MEMBERSHIP_INCLUDE, tenant: { select: { id: true, nombre: true } } }, }) : await prisma.tenantMembership.findFirst({ where: { userId, active: true }, include: { ...MEMBERSHIP_INCLUDE, tenant: { select: { id: true, nombre: true } } }, orderBy: { joinedAt: 'asc' }, }); if (!m) throw new Error('Usuario sin memberships activas'); return mapMembershipRow(m, true); } export async function deleteUsuarioGlobal(userId: string): Promise { // Hard delete del user — cascade borra todas las memberships, refresh // tokens, password reset tokens, platform roles, etc. await prisma.user.delete({ where: { id: userId } }); }