initial commit

This commit is contained in:
2026-06-09 14:07:37 +03:30
parent 30ac533800
commit 996a4fcda7
121 changed files with 20557 additions and 3 deletions

View File

@@ -0,0 +1,92 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsEmail,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
import { ClientType } from '../../common/enums/client-type.enum';
import { Role } from '../../common/enums/role.enum';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
export class CreateUserDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
fullName!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
username!: string;
@ApiProperty()
@IsEmail()
email!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
mobile?: string;
@ApiProperty({ minLength: 8 })
@IsString()
@MinLength(8)
password!: string;
@ApiProperty({ enum: Role, default: Role.USER })
@IsEnum(Role)
role!: Role;
@ApiProperty({ enum: ClientType, default: ClientType.EXTERNAL })
@IsEnum(ClientType)
clientType!: ClientType;
@ApiPropertyOptional()
@IsOptional()
@IsString()
appName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
clientName?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
type: [String],
example: [InquiryType.PERSON, InquiryType.POSTAL_CODE],
})
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
allowedInquiries?: string[];
@ApiPropertyOptional({ default: 60 })
@IsOptional()
@IsInt()
@Min(1)
requestLimitPerMinute?: number;
@ApiPropertyOptional({ default: 10000 })
@IsOptional()
@IsInt()
@Min(1)
requestLimitPerDay?: number;
@ApiPropertyOptional()
@IsOptional()
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ResetPasswordDto {
@ApiProperty({ minLength: 8 })
@IsString()
@IsNotEmpty()
@MinLength(8)
newPassword!: string;
}

View File

@@ -0,0 +1,79 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsEmail,
IsEnum,
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
import { ClientType } from '../../common/enums/client-type.enum';
import { Role } from '../../common/enums/role.enum';
export class UpdateUserDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
fullName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
mobile?: string;
@ApiPropertyOptional({ enum: Role })
@IsOptional()
@IsEnum(Role)
role?: Role;
@ApiPropertyOptional({ enum: ClientType })
@IsOptional()
@IsEnum(ClientType)
clientType?: ClientType;
@ApiPropertyOptional()
@IsOptional()
@IsString()
appName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
clientName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
allowedInquiries?: string[];
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
requestLimitPerMinute?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
requestLimitPerDay?: number;
@ApiPropertyOptional()
@IsOptional()
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,62 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ClientType } from '../../common/enums/client-type.enum';
import { Role } from '../../common/enums/role.enum';
export class UserResponseDto {
@ApiProperty()
id!: string;
@ApiProperty()
fullName!: string;
@ApiProperty()
username!: string;
@ApiProperty()
email!: string;
@ApiPropertyOptional()
mobile?: string;
@ApiProperty({ enum: Role })
role!: Role;
@ApiProperty({ enum: ClientType })
clientType!: ClientType;
@ApiProperty()
appName!: string;
@ApiProperty()
clientName!: string;
@ApiProperty()
isActive!: boolean;
@ApiProperty()
isBlocked!: boolean;
@ApiProperty({ type: [String] })
allowedInquiries!: string[];
@ApiProperty()
requestLimitPerMinute!: number;
@ApiProperty()
requestLimitPerDay!: number;
@ApiProperty()
totalRequests!: number;
@ApiPropertyOptional()
lastLoginAt?: Date;
@ApiPropertyOptional()
passwordChangedAt?: Date;
@ApiProperty()
createdAt!: Date;
@ApiProperty()
updatedAt!: Date;
}

View File

@@ -0,0 +1,34 @@
import { UserDocument } from '../schemas/user.schema';
import { ProfileDto } from '../../auth/dto/profile.dto';
import { UserResponseDto } from '../dto/user-response.dto';
/** Maps Mongoose user documents to API DTOs (never exposes password/hash fields). */
export class UserMapper {
static toProfileDto(user: UserDocument): ProfileDto {
return {
id: user._id.toString(),
fullName: user.fullName,
username: user.username,
email: user.email,
mobile: user.mobile,
role: user.role,
clientType: user.clientType,
appName: user.appName,
clientName: user.clientName,
isActive: user.isActive,
isBlocked: user.isBlocked,
allowedInquiries: user.allowedInquiries,
requestLimitPerMinute: user.requestLimitPerMinute,
requestLimitPerDay: user.requestLimitPerDay,
totalRequests: user.totalRequests,
lastLoginAt: user.lastLoginAt,
passwordChangedAt: user.passwordChangedAt,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
static toResponseDto(user: UserDocument): UserResponseDto {
return UserMapper.toProfileDto(user);
}
}

View File

@@ -0,0 +1,85 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { ClientType } from '../../common/enums/client-type.enum';
import { Role } from '../../common/enums/role.enum';
export type UserDocument = HydratedDocument<User>;
/**
* Unified user model — internal admins and external API clients share one collection.
* Designed for future extensions: API keys, HMAC, IP whitelist, permissions, multi-tenant.
*/
@Schema({ timestamps: true, collection: 'users' })
export class User {
@Prop({ required: true, trim: true })
fullName!: string;
@Prop({ required: true, unique: true, lowercase: true, trim: true, index: true })
username!: string;
@Prop({ required: true, unique: true, lowercase: true, trim: true, index: true })
email!: string;
@Prop({ trim: true })
mobile?: string;
@Prop({ required: true, select: false })
password!: string;
@Prop({ required: true, enum: Role, default: Role.USER, index: true })
role!: Role;
@Prop({ required: true, enum: ClientType, default: ClientType.EXTERNAL, index: true })
clientType!: ClientType;
@Prop({ trim: true, default: '' })
appName!: string;
@Prop({ trim: true, default: '' })
clientName!: string;
@Prop({ default: true })
isActive!: boolean;
@Prop({ default: false })
isBlocked!: boolean;
/** Future: machine-to-machine auth without JWT. */
@Prop({ select: false })
apiKey?: string;
@Prop({ type: [String], default: [] })
allowedInquiries!: string[];
@Prop({ default: 60 })
requestLimitPerMinute!: number;
@Prop({ default: 10000 })
requestLimitPerDay!: number;
@Prop({ default: 0 })
totalRequests!: number;
@Prop()
lastLoginAt?: Date;
@Prop()
passwordChangedAt?: Date;
@Prop({ type: Types.ObjectId, ref: 'User' })
createdBy?: Types.ObjectId;
@Prop({ select: false })
refreshToken?: string;
@Prop({ type: Object, default: {} })
metadata!: Record<string, unknown>;
createdAt!: Date;
updatedAt!: Date;
}
export const UserSchema = SchemaFactory.createForClass(User);
UserSchema.index({ role: 1, isActive: 1 });
UserSchema.index({ clientType: 1, appName: 1 });

View File

@@ -0,0 +1,128 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Request } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { ADMIN_ROLES, Role } from '../common/enums/role.enum';
import { CreateUserDto } from './dto/create-user.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { UsersService } from './users.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AuthenticatedUser } from '../auth/interfaces/authenticated-user.interface';
import { UserMapper } from './mappers/user.mapper';
@ApiTags('Users')
@ApiBearerAuth()
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@Roles(...ADMIN_ROLES)
@ApiOperation({ summary: 'Create a new user (ADMIN / SUPER_ADMIN only)' })
@ApiResponse({ status: 201, type: UserResponseDto })
async create(
@Body() dto: CreateUserDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() req: Request,
): Promise<UserResponseDto> {
return this.usersService.create(dto, actor, {
ip: req.ip,
userAgent: req.headers['user-agent'],
});
}
@Get()
@Roles(...ADMIN_ROLES)
@ApiOperation({ summary: 'List all users' })
@ApiResponse({ status: 200, type: [UserResponseDto] })
async findAll(): Promise<UserResponseDto[]> {
return this.usersService.findAll();
}
@Get(':id')
@Roles(...ADMIN_ROLES)
@ApiOperation({ summary: 'Get user by ID' })
@ApiResponse({ status: 200, type: UserResponseDto })
async findOne(@Param('id') id: string): Promise<UserResponseDto> {
const user = await this.usersService.findById(id);
return UserMapper.toResponseDto(user);
}
@Patch(':id')
@Roles(...ADMIN_ROLES)
@ApiOperation({ summary: 'Update user' })
@ApiResponse({ status: 200, type: UserResponseDto })
async update(
@Param('id') id: string,
@Body() dto: UpdateUserDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<UserResponseDto> {
return this.usersService.update(id, dto, actor);
}
@Patch(':id/block')
@Roles(...ADMIN_ROLES)
@ApiOperation({ summary: 'Block user' })
@ApiResponse({ status: 200, type: UserResponseDto })
async block(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() req: Request,
): Promise<UserResponseDto> {
return this.usersService.block(id, actor, {
ip: req.ip,
userAgent: req.headers['user-agent'],
});
}
@Patch(':id/unblock')
@Roles(...ADMIN_ROLES)
@ApiOperation({ summary: 'Unblock user' })
@ApiResponse({ status: 200, type: UserResponseDto })
async unblock(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() req: Request,
): Promise<UserResponseDto> {
return this.usersService.unblock(id, actor, {
ip: req.ip,
userAgent: req.headers['user-agent'],
});
}
@Patch(':id/reset-password')
@Roles(...ADMIN_ROLES)
@ApiOperation({ summary: 'Reset user password' })
@ApiResponse({ status: 200, description: 'Password reset successfully' })
async resetPassword(
@Param('id') id: string,
@Body() dto: ResetPasswordDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() req: Request,
): Promise<{ message: string }> {
await this.usersService.resetPassword(id, dto, actor, {
ip: req.ip,
userAgent: req.headers['user-agent'],
});
return { message: 'Password reset successfully' };
}
}

17
src/users/users.module.ts Normal file
View File

@@ -0,0 +1,17 @@
import { Module, forwardRef } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AuthModule } from '../auth/auth.module';
import { User, UserSchema } from './schemas/user.schema';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
forwardRef(() => AuthModule),
],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService, MongooseModule],
})
export class UsersModule {}

251
src/users/users.service.ts Normal file
View 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')}`;
}
}