import { ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { Prisma } from '../../generated/prisma'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; const BCRYPT_SALT_ROUNDS = 12; const USER_WITH_ROLE = { include: { role: true }, } satisfies { include: Prisma.UserInclude }; type UserWithRole = Prisma.UserGetPayload; export interface UserSummary { id: string; email: string; name: string; roleId: string; roleKey: string; roleName: string; isActive: boolean; createdAt: Date; deactivatedAt: Date | null; mfaEnabled: boolean; } @Injectable() export class UsersService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly permissionResolver: PermissionResolverService, ) {} private toSummary(user: UserWithRole): UserSummary { return { id: user.id, email: user.email, name: user.name, roleId: user.roleId, roleKey: user.role.key, roleName: user.role.name, isActive: user.isActive, createdAt: user.createdAt, deactivatedAt: user.deactivatedAt, mfaEnabled: user.mfaEnabled, }; } private async assertActiveRole(roleId: string): Promise { const role = await this.prisma.role.findUnique({ where: { id: roleId } }); if (!role) { throw new NotFoundException('Role not found'); } if (role.status !== 'ACTIVE') { throw new ConflictException('Role is not active'); } } /** * Blocks removing (by deactivation or role reassignment) the last active * user still holding a system role (ADMIN). Without this, an operator * could lock the app out of user/role management entirely, with no * self-service recovery: prisma/seed.ts skips seeding once a user with * its configured email already exists, so it cannot restore access to a * merely demoted/deactivated account. */ private async assertNotLastSystemRoleUser(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { roleId: true, role: { select: { isSystem: true } } }, }); if (!user || !user.role.isSystem) { return; } const activeCount = await this.prisma.user.count({ where: { roleId: user.roleId, isActive: true }, }); if (activeCount <= 1) { throw new ConflictException( 'Cannot remove the last active user holding a system role', ); } } async findAll(): Promise { const users = await this.prisma.user.findMany({ ...USER_WITH_ROLE, orderBy: { createdAt: 'asc' }, }); return users.map((user) => this.toSummary(user)); } async findById(id: string): Promise { const user = await this.prisma.user.findUnique({ where: { id }, ...USER_WITH_ROLE, }); if (!user) { throw new NotFoundException('User not found'); } return user; } async findSummaryById(id: string): Promise { return this.toSummary(await this.findById(id)); } async findByEmail(email: string): Promise { return this.prisma.user.findUnique({ where: { email }, ...USER_WITH_ROLE }); } async create( dto: CreateUserDto, actingUserId?: string, ): Promise { const existing = await this.findByEmail(dto.email); if (existing) { throw new ConflictException('Email already in use'); } await this.assertActiveRole(dto.roleId); const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS); const user = await this.prisma.user.create({ data: { email: dto.email, passwordHash, name: dto.name, roleId: dto.roleId, }, ...USER_WITH_ROLE, }); await this.auditLog.record({ entityType: 'User', entityId: user.id, action: 'CREATE', userId: actingUserId, afterData: this.toSummary(user), }); return this.toSummary(user); } async update( id: string, dto: UpdateUserDto, actingUserId?: string, ): Promise { const before = await this.findById(id); const roleChanged = Boolean(dto.roleId) && dto.roleId !== before.roleId; if (dto.roleId) { await this.assertActiveRole(dto.roleId); } if (roleChanged) { await this.assertNotLastSystemRoleUser(id); } const user = await this.prisma.user.update({ where: { id }, data: { name: dto.name, roleId: dto.roleId, }, ...USER_WITH_ROLE, }); if (roleChanged) { this.permissionResolver.invalidateUser(id); } await this.auditLog.record({ entityType: 'User', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: this.toSummary(before), afterData: this.toSummary(user), }); return this.toSummary(user); } async deactivate(id: string, actingUserId?: string): Promise { const before = await this.findById(id); await this.assertNotLastSystemRoleUser(id); const user = await this.prisma.user.update({ where: { id }, data: { isActive: false, deactivatedAt: new Date() }, ...USER_WITH_ROLE, }); this.permissionResolver.invalidateUser(id); await this.auditLog.record({ entityType: 'User', entityId: id, action: 'DEACTIVATE', userId: actingUserId, beforeData: this.toSummary(before), afterData: this.toSummary(user), }); return this.toSummary(user); } /** Recovery path for a user who lost their MFA device (no email-reset flow exists in this project). */ async resetMfa(id: string, actingUserId?: string): Promise { await this.findById(id); const user = await this.prisma.user.update({ where: { id }, data: { mfaEnabled: false, mfaSecret: null }, ...USER_WITH_ROLE, }); await this.auditLog.record({ entityType: 'User', entityId: id, action: 'MFA_DISABLED', userId: actingUserId, }); return this.toSummary(user); } async activate(id: string, actingUserId?: string): Promise { const before = await this.findById(id); if (before.isActive) { throw new ConflictException('User is already active'); } const user = await this.prisma.user.update({ where: { id }, data: { isActive: true, deactivatedAt: null }, ...USER_WITH_ROLE, }); this.permissionResolver.invalidateUser(id); await this.auditLog.record({ entityType: 'User', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: this.toSummary(before), afterData: this.toSummary(user), }); return this.toSummary(user); } }