forked from Shared/esg
initial commit
This commit is contained in:
251
src/users/users.service.ts
Normal file
251
src/users/users.service.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
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')}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user