- 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
327 lines
11 KiB
TypeScript
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 } });
|
|
}
|