forked from Shared/esg
252 lines
8.0 KiB
TypeScript
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')}`;
|
|
}
|
|
}
|