Files
esg/src/users/users.service.ts
2026-06-09 14:07:37 +03:30

252 lines
8.0 KiB
TypeScript

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<UserDocument>,
private readonly passwordService: PasswordService,
private readonly auditService: AuditService,
) {}
async findById(id: string): Promise<UserDocument> {
const user = await this.userModel.findById(id);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
async findByUsername(username: string, includePassword = false): Promise<UserDocument | null> {
const query = this.userModel.findOne({ username: username.toLowerCase().trim() });
if (includePassword) {
query.select('+password +refreshToken');
}
return query.exec();
}
async findAll(): Promise<UserResponseDto[]> {
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<UserResponseDto> {
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<UserResponseDto> {
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<UserResponseDto> {
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<UserResponseDto> {
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<void> {
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<void> {
const hash = await this.passwordService.hash(refreshToken);
await this.userModel.findByIdAndUpdate(userId, { refreshToken: hash });
}
async clearRefreshToken(userId: string): Promise<void> {
await this.userModel.findByIdAndUpdate(userId, { $unset: { refreshToken: 1 } });
}
async validateRefreshToken(userId: string, refreshToken: string): Promise<boolean> {
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<void> {
await this.userModel.findByIdAndUpdate(userId, { lastLoginAt: new Date() });
}
async incrementTotalRequests(userId: string): Promise<void> {
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')}`;
}
}