import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model, Types } from 'mongoose'; import { randomBytes } from 'crypto'; import { AuditEvent } from '../common/enums/audit-event.enum'; import { ADMIN_ROLES, Role } from '../common/enums/role.enum'; import { AuditService } from '../audit/audit.service'; import { PasswordService } from '../auth/services/password.service'; import { AuthenticatedUser } from '../auth/interfaces/authenticated-user.interface'; import { CreateUserDto } from './dto/create-user.dto'; import { ResetPasswordDto } from './dto/reset-password.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UserMapper } from './mappers/user.mapper'; import { User, UserDocument } from './schemas/user.schema'; import { UserResponseDto } from './dto/user-response.dto'; export interface RequestContext { ip?: string; userAgent?: string; } @Injectable() export class UsersService { constructor( @InjectModel(User.name) private readonly userModel: Model, private readonly passwordService: PasswordService, private readonly auditService: AuditService, ) {} async findById(id: string): Promise { const user = await this.userModel.findById(id); if (!user) { throw new NotFoundException('User not found'); } return user; } async findByUsername(username: string, includePassword = false): Promise { const query = this.userModel.findOne({ username: username.toLowerCase().trim() }); if (includePassword) { query.select('+password +refreshToken'); } return query.exec(); } async findAll(): Promise { const users = await this.userModel.find().sort({ createdAt: -1 }); return users.map((u) => UserMapper.toResponseDto(u)); } async create( dto: CreateUserDto, actor: AuthenticatedUser, context: RequestContext = {}, ): Promise { this.assertCanAssignRole(actor.role, dto.role); const username = dto.username.toLowerCase().trim(); const email = dto.email.toLowerCase().trim(); const existing = await this.userModel.findOne({ $or: [{ username }, { email }], }); if (existing) { throw new ConflictException('Username or email already exists'); } const passwordHash = await this.passwordService.hash(dto.password); const apiKey = this.generateApiKey(); const user = await this.userModel.create({ fullName: dto.fullName, username, email, mobile: dto.mobile, password: passwordHash, role: dto.role, clientType: dto.clientType, appName: dto.appName ?? '', clientName: dto.clientName ?? '', isActive: dto.isActive ?? true, allowedInquiries: dto.allowedInquiries ?? [], requestLimitPerMinute: dto.requestLimitPerMinute ?? 60, requestLimitPerDay: dto.requestLimitPerDay ?? 10000, apiKey, passwordChangedAt: new Date(), createdBy: new Types.ObjectId(actor.id), metadata: dto.metadata ?? {}, }); await this.auditService.log(AuditEvent.USER_CREATED, { actorId: actor.id, userId: user._id.toString(), username: user.username, ip: context.ip, userAgent: context.userAgent, metadata: { role: user.role, clientType: user.clientType }, }); return UserMapper.toResponseDto(user); } async update(id: string, dto: UpdateUserDto, actor: AuthenticatedUser): Promise { const user = await this.findById(id); if (dto.role !== undefined) { this.assertCanAssignRole(actor.role, dto.role); } if (dto.email) { const email = dto.email.toLowerCase().trim(); const conflict = await this.userModel.findOne({ email, _id: { $ne: id } }); if (conflict) { throw new ConflictException('Email already in use'); } user.email = email; } Object.assign(user, { ...(dto.fullName !== undefined && { fullName: dto.fullName }), ...(dto.mobile !== undefined && { mobile: dto.mobile }), ...(dto.role !== undefined && { role: dto.role }), ...(dto.clientType !== undefined && { clientType: dto.clientType }), ...(dto.appName !== undefined && { appName: dto.appName }), ...(dto.clientName !== undefined && { clientName: dto.clientName }), ...(dto.isActive !== undefined && { isActive: dto.isActive }), ...(dto.allowedInquiries !== undefined && { allowedInquiries: dto.allowedInquiries }), ...(dto.requestLimitPerMinute !== undefined && { requestLimitPerMinute: dto.requestLimitPerMinute, }), ...(dto.requestLimitPerDay !== undefined && { requestLimitPerDay: dto.requestLimitPerDay }), ...(dto.metadata !== undefined && { metadata: dto.metadata }), }); await user.save(); return UserMapper.toResponseDto(user); } async block( id: string, actor: AuthenticatedUser, context: RequestContext = {}, ): Promise { const user = await this.findById(id); if (user._id.toString() === actor.id) { throw new BadRequestException('Cannot block your own account'); } user.isBlocked = true; user.refreshToken = undefined; await user.save(); await this.auditService.log(AuditEvent.USER_BLOCKED, { actorId: actor.id, userId: id, username: user.username, ip: context.ip, userAgent: context.userAgent, }); return UserMapper.toResponseDto(user); } async unblock( id: string, actor: AuthenticatedUser, context: RequestContext = {}, ): Promise { const user = await this.findById(id); user.isBlocked = false; await user.save(); await this.auditService.log(AuditEvent.USER_UNBLOCKED, { actorId: actor.id, userId: id, username: user.username, ip: context.ip, userAgent: context.userAgent, }); return UserMapper.toResponseDto(user); } async resetPassword( id: string, dto: ResetPasswordDto, actor: AuthenticatedUser, context: RequestContext = {}, ): Promise { const user = await this.userModel.findById(id).select('+password +refreshToken'); if (!user) { throw new NotFoundException('User not found'); } user.password = await this.passwordService.hash(dto.newPassword); user.passwordChangedAt = new Date(); user.refreshToken = undefined; await user.save(); await this.auditService.log(AuditEvent.PASSWORD_RESET, { actorId: actor.id, userId: id, username: user.username, ip: context.ip, userAgent: context.userAgent, }); } async saveRefreshToken(userId: string, refreshToken: string): Promise { const hash = await this.passwordService.hash(refreshToken); await this.userModel.findByIdAndUpdate(userId, { refreshToken: hash }); } async clearRefreshToken(userId: string): Promise { await this.userModel.findByIdAndUpdate(userId, { $unset: { refreshToken: 1 } }); } async validateRefreshToken(userId: string, refreshToken: string): Promise { const user = await this.userModel.findById(userId).select('+refreshToken'); if (!user?.refreshToken) { return false; } return this.passwordService.compare(refreshToken, user.refreshToken); } async recordLogin(userId: string): Promise { await this.userModel.findByIdAndUpdate(userId, { lastLoginAt: new Date() }); } async incrementTotalRequests(userId: string): Promise { await this.userModel.findByIdAndUpdate(userId, { $inc: { totalRequests: 1 } }); } private assertCanAssignRole(actorRole: Role, targetRole: Role): void { if (targetRole === Role.SUPER_ADMIN && actorRole !== Role.SUPER_ADMIN) { throw new ForbiddenException('Only SUPER_ADMIN can assign SUPER_ADMIN role'); } if (!ADMIN_ROLES.includes(actorRole)) { throw new ForbiddenException('Insufficient permissions to manage users'); } } private generateApiKey(): string { return `esg_${randomBytes(32).toString('hex')}`; } }