Files
HoruxDespachosNuevo/apps/api/src/services/usuarios.service.ts
Horux Dev f43cb165c6 feat: asignaciones obligaciones/tareas + fixes backend
- Migracion 046: tablas obligacion_asignaciones y tarea_asignaciones
- Servicio y controller de asignaciones (CRUD + listados)
- Fix: enviar correo welcome al invitar usuario nuevo
- Fix: quitar JOIN users de queries tenant (usar Prisma en BD central)
- Fix: req.params.obligacionId correcto en asignaciones controller
- Fix: orden rutas estaticas antes de dinamicas en cartera.routes
- Fix: owner/cfo ven todas las asignaciones en getAsignacionesPorSupervisor
- Fix: validar que entidad pertenezca a cartera padre en subcartera
- Nuevo endpoint GET /carteras/asignaciones/sin-asignar
- Nuevo endpoint GET /tareas/mis-tareas
2026-05-23 23:40:12 +00:00

327 lines
11 KiB
TypeScript

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<number> {
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<UserListItem[]> {
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<UserListItem> {
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<UserListItem> {
// 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<void> {
// 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<UserListItem[]> {
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<UserListItem> {
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<UserListItem> {
// 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<void> {
// Hard delete del user — cascade borra todas las memberships, refresh
// tokens, password reset tokens, platform roles, etc.
await prisma.user.delete({ where: { id: userId } });
}