import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager, In } from 'typeorm'; import { AuditService } from '../../audit/audit.service'; import { PasswordService } from '../../auth/services/password.service'; import type { AuthPrincipal, RequestWithContext, } from '../../common/http/request-context'; import { AuthSessionsRepository } from '../../core-data/repositories/auth-sessions.repository'; import { AuditAction, Role, User, UserRole, UserStatus, } from '../../database/entities'; import { administrationAuditContext, assertAdministrativeRecoveryRemains, isUniqueViolation, } from '../common/administration-audit'; import type { ChangeUserStatusDto } from './dto/change-user-status.dto'; import type { CreateUserDto } from './dto/create-user.dto'; import type { ListUsersQueryDto } from './dto/list-users-query.dto'; import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto'; import type { ResetUserPasswordDto } from './dto/reset-user-password.dto'; import type { UpdateUserDto } from './dto/update-user.dto'; export interface UserRoleView { id: string; code: string; name: string; } export interface AdministrativeUserView { id: string; username: string; email: string | null; dni: string | null; phone: string | null; jobTitle: string | null; employeeNumber: string | null; firstName: string; lastName: string; status: UserStatus; mustChangePassword: boolean; failedLoginAttempts: number; lockedUntil: Date | null; lastLoginAt: Date | null; passwordChangedAt: Date | null; createdAt: Date; updatedAt: Date; roles: UserRoleView[]; } interface UserViewRow extends AdministrativeUserView { total?: string | number; } function userNotFound(): NotFoundException { return new NotFoundException({ code: 'USER_NOT_FOUND', message: 'Usuario no encontrado', }); } function roleSelectionInvalid(): BadRequestException { return new BadRequestException({ code: 'ROLE_NOT_FOUND', message: 'Uno o más roles no existen', }); } function inspectorEmailRequired(): BadRequestException { return new BadRequestException({ code: 'INSPECTOR_EMAIL_REQUIRED', message: 'Los usuarios con rol Inspector deben tener un email válido para recibir la documentación de sus inspecciones', }); } @Injectable() export class UsersService { constructor( private readonly dataSource: DataSource, private readonly passwords: PasswordService, private readonly sessions: AuthSessionsRepository, private readonly audit: AuditService, ) {} async list(query: ListUsersQueryDto) { const page = query.page; const pageSize = query.pageSize; const filters: string[] = []; const parameters: unknown[] = []; const search = query.search?.trim(); if (search) { parameters.push(`%${search}%`); filters.push(` ( user_account.username ILIKE $${parameters.length} OR user_account.email ILIKE $${parameters.length} OR user_account.first_name ILIKE $${parameters.length} OR user_account.last_name ILIKE $${parameters.length} OR user_account.dni ILIKE $${parameters.length} OR user_account.phone ILIKE $${parameters.length} OR user_account.job_title ILIKE $${parameters.length} OR user_account.employee_number ILIKE $${parameters.length} ) `); } if (query.status) { parameters.push(query.status); filters.push(`user_account.status = $${parameters.length}`); } parameters.push(pageSize, (page - 1) * pageSize); const limitParameter = parameters.length - 1; const offsetParameter = parameters.length; const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''; const rows = (await this.dataSource.query( ` SELECT user_account.id, user_account.username, user_account.email, user_account.dni, user_account.phone, user_account.job_title AS "jobTitle", user_account.employee_number AS "employeeNumber", user_account.first_name AS "firstName", user_account.last_name AS "lastName", user_account.status, user_account.must_change_password AS "mustChangePassword", user_account.failed_login_attempts AS "failedLoginAttempts", user_account.locked_until AS "lockedUntil", user_account.last_login_at AS "lastLoginAt", user_account.password_changed_at AS "passwordChangedAt", user_account.created_at AS "createdAt", user_account.updated_at AS "updatedAt", COALESCE( JSONB_AGG( JSONB_BUILD_OBJECT( 'id', role.id, 'code', role.code, 'name', role.name ) ORDER BY role.code ) FILTER (WHERE role.id IS NOT NULL), '[]'::jsonb ) AS roles, COUNT(*) OVER() AS total FROM users user_account LEFT JOIN user_roles user_role ON user_role.user_id = user_account.id LEFT JOIN roles role ON role.id = user_role.role_id ${where} GROUP BY user_account.id ORDER BY user_account.created_at DESC, user_account.username ASC LIMIT $${limitParameter} OFFSET $${offsetParameter} `, parameters, )) as UserViewRow[]; const total = rows.length > 0 ? Number(rows[0].total ?? 0) : 0; return { data: rows.map(({ total: _total, ...row }) => row), meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize), }, }; } async getById(id: string): Promise { return this.dataSource.transaction(async (manager) => this.loadUserView(manager, id), ); } async create( dto: CreateUserDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { const passwordHash = await this.passwords.hash(dto.password); try { return await this.dataSource.transaction(async (manager) => { const roles = await this.resolveRoles(manager, dto.roleIds); this.assertInspectorHasEmail(roles, dto.email ?? null); const user = manager.getRepository(User).create({ username: dto.username.trim().toLowerCase(), email: dto.email?.trim().toLowerCase() || null, dni: dto.dni ?? null, phone: dto.phone ?? null, jobTitle: dto.jobTitle ?? null, employeeNumber: dto.employeeNumber ?? null, passwordHash, firstName: dto.firstName.trim(), lastName: dto.lastName.trim(), status: UserStatus.ACTIVE, mustChangePassword: dto.mustChangePassword, failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: null, passwordChangedAt: null, createdBy: principal.userId, updatedBy: principal.userId, }); await manager.getRepository(User).save(user); await this.insertUserRoles( manager, user.id, roles, principal.userId, ); const created = await this.loadUserView(manager, user.id); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.USER_CREATED, entityType: 'user', entityId: user.id, afterData: { ...created }, }, manager, ); return created; }); } catch (error) { if (isUniqueViolation(error)) throw this.userConflict(); throw error; } } async update( id: string, dto: UpdateUserDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { if ( dto.username === undefined && dto.email === undefined && dto.dni === undefined && dto.phone === undefined && dto.jobTitle === undefined && dto.employeeNumber === undefined && dto.firstName === undefined && dto.lastName === undefined ) { throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios', }); } try { return await this.dataSource.transaction(async (manager) => { const user = await this.lockUser(manager, id); const before = await this.loadUserView(manager, id); if (dto.email !== undefined && !dto.email && before.roles.some((role) => role.code === 'inspector')) { throw inspectorEmailRequired(); } if (dto.username !== undefined) { user.username = dto.username.trim().toLowerCase(); } if (dto.email !== undefined) { user.email = dto.email?.trim().toLowerCase() || null; } if (dto.dni !== undefined) user.dni = dto.dni ?? null; if (dto.phone !== undefined) user.phone = dto.phone ?? null; if (dto.jobTitle !== undefined) user.jobTitle = dto.jobTitle ?? null; if (dto.employeeNumber !== undefined) user.employeeNumber = dto.employeeNumber ?? null; if (dto.firstName !== undefined) user.firstName = dto.firstName.trim(); if (dto.lastName !== undefined) user.lastName = dto.lastName.trim(); user.updatedBy = principal.userId; await manager.getRepository(User).save(user); const updated = await this.loadUserView(manager, id); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.USER_UPDATED, entityType: 'user', entityId: id, beforeData: { ...before }, afterData: { ...updated }, }, manager, ); return updated; }); } catch (error) { if (isUniqueViolation(error)) throw this.userConflict(); throw error; } } async changeStatus( id: string, dto: ChangeUserStatusDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { if (id === principal.userId && dto.status === UserStatus.INACTIVE) { throw new ConflictException({ code: 'SELF_DEACTIVATION_FORBIDDEN', message: 'No puede desactivar su propio usuario', }); } return this.dataSource.transaction(async (manager) => { const user = await this.lockUser(manager, id); const before = await this.loadUserView(manager, id); if (user.status === dto.status) return before; user.status = dto.status; user.updatedBy = principal.userId; if (dto.status === UserStatus.ACTIVE) { user.failedLoginAttempts = 0; user.lockedUntil = null; } await manager.getRepository(User).save(user); if (dto.status === UserStatus.INACTIVE) { await this.sessions.revokeUserSessions(id, undefined, manager); } await assertAdministrativeRecoveryRemains(manager); const updated = await this.loadUserView(manager, id); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.USER_STATUS_CHANGED, entityType: 'user', entityId: id, beforeData: { status: before.status }, afterData: { status: updated.status }, metadata: { sessionsRevoked: dto.status === UserStatus.INACTIVE, }, }, manager, ); return updated; }); } async resetPassword( id: string, dto: ResetUserPasswordDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { const passwordHash = await this.passwords.hash(dto.password); return this.dataSource.transaction(async (manager) => { const user = await this.lockUser(manager, id); const before = await this.loadUserView(manager, id); const now = new Date(); user.passwordHash = passwordHash; user.passwordChangedAt = now; user.mustChangePassword = dto.mustChangePassword; user.failedLoginAttempts = 0; user.lockedUntil = null; user.updatedBy = principal.userId; await manager.getRepository(User).save(user); await this.sessions.revokeUserSessions(id, undefined, manager); const updated = await this.loadUserView(manager, id); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.USER_PASSWORD_RESET, entityType: 'user', entityId: id, beforeData: { mustChangePassword: before.mustChangePassword, failedLoginAttempts: before.failedLoginAttempts, lockedUntil: before.lockedUntil, }, afterData: { mustChangePassword: updated.mustChangePassword, failedLoginAttempts: updated.failedLoginAttempts, lockedUntil: updated.lockedUntil, passwordChangedAt: updated.passwordChangedAt, }, metadata: { sessionsRevoked: true, passwordValueRecorded: false }, }, manager, ); return updated; }); } async replaceRoles( id: string, dto: ReplaceUserRolesDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { await this.lockUser(manager, id); const roles = await this.resolveRoles(manager, dto.roleIds); const before = await this.loadUserView(manager, id); this.assertInspectorHasEmail(roles, before.email); const beforeIds = before.roles.map((role) => role.id).sort(); const afterIds = roles.map((role) => role.id).sort(); if (beforeIds.join(',') === afterIds.join(',')) return before; await manager.getRepository(UserRole).delete({ userId: id }); await this.insertUserRoles(manager, id, roles, principal.userId); await assertAdministrativeRecoveryRemains(manager); const updated = await this.loadUserView(manager, id); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.USER_ROLES_CHANGED, entityType: 'user', entityId: id, beforeData: { roles: before.roles }, afterData: { roles: updated.roles }, }, manager, ); return updated; }); } private async resolveRoles( manager: EntityManager, roleIds: string[], ): Promise { const uniqueIds = [...new Set(roleIds)]; if (uniqueIds.length === 0) return []; const roles = await manager.getRepository(Role).find({ where: { id: In(uniqueIds) }, order: { code: 'ASC' }, }); if (roles.length !== uniqueIds.length) throw roleSelectionInvalid(); return roles; } private assertInspectorHasEmail(roles: Role[], email: string | null | undefined): void { if (roles.some((role) => role.code === 'inspector') && !email?.trim()) { throw inspectorEmailRequired(); } } private async insertUserRoles( manager: EntityManager, userId: string, roles: Role[], assignedBy: string, ): Promise { if (roles.length === 0) return; const assignments = roles.map((role) => manager.getRepository(UserRole).create({ userId, roleId: role.id, assignedBy, }), ); await manager.getRepository(UserRole).save(assignments); } private async lockUser(manager: EntityManager, id: string): Promise { const user = await manager .getRepository(User) .createQueryBuilder('user') .where('user.id = :id', { id }) .setLock('pessimistic_write') .getOne(); if (!user) throw userNotFound(); return user; } private async loadUserView( manager: EntityManager, id: string, ): Promise { const [row] = (await manager.query( ` SELECT user_account.id, user_account.username, user_account.email, user_account.dni, user_account.phone, user_account.job_title AS "jobTitle", user_account.employee_number AS "employeeNumber", user_account.first_name AS "firstName", user_account.last_name AS "lastName", user_account.status, user_account.must_change_password AS "mustChangePassword", user_account.failed_login_attempts AS "failedLoginAttempts", user_account.locked_until AS "lockedUntil", user_account.last_login_at AS "lastLoginAt", user_account.password_changed_at AS "passwordChangedAt", user_account.created_at AS "createdAt", user_account.updated_at AS "updatedAt", COALESCE( JSONB_AGG( JSONB_BUILD_OBJECT( 'id', role.id, 'code', role.code, 'name', role.name ) ORDER BY role.code ) FILTER (WHERE role.id IS NOT NULL), '[]'::jsonb ) AS roles FROM users user_account LEFT JOIN user_roles user_role ON user_role.user_id = user_account.id LEFT JOIN roles role ON role.id = user_role.role_id WHERE user_account.id = $1 GROUP BY user_account.id `, [id], )) as AdministrativeUserView[]; if (!row) throw userNotFound(); return row; } private userConflict(): ConflictException { return new ConflictException({ code: 'USER_ALREADY_EXISTS', message: 'El usuario, email o DNI ya está registrado', }); } }