هذا الالتزام موجود في:
2026-04-20 15:12:16 +03:00
التزام 28f7241bcd
172 ملفات معدلة مع 21907 إضافات و0 حذوفات

عرض الملف

@@ -0,0 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, Length } from 'class-validator';
export class AdminDisableUserDto {
@ApiPropertyOptional({ example: 'Violation of community guidelines' })
@IsOptional()
@IsString()
@Length(0, 300)
reason?: string;
}

عرض الملف

@@ -0,0 +1,27 @@
import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateAdminDto {
@IsString()
@IsNotEmpty()
@MaxLength(80)
name!: string;
@IsString()
@IsNotEmpty()
@MinLength(3)
@MaxLength(30)
username!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
@MaxLength(128)
password!: string;
@IsString()
@MinLength(8)
@MaxLength(128)
confirmPassword!: string;
}

عرض الملف

@@ -0,0 +1,115 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsArray,
IsBoolean,
IsEmail,
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUrl,
Length,
Max,
Matches,
Min,
} from 'class-validator';
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
import { MusicRole } from '../../../common/enums/music-role.enum';
export class CreateUserDto {
@ApiProperty({ example: 'John Doe' })
@IsString()
@Length(2, 80)
name!: string;
@ApiProperty({ required: false, example: 'Artist One' })
@IsOptional()
@IsString()
@Length(0, 80)
stageName?: string;
@ApiProperty({ example: 'john_doe' })
@IsString()
@Length(3, 30)
@Matches(/^[a-zA-Z0-9_.]+$/, { message: 'username can contain letters, numbers, _ and .' })
username!: string;
@ApiProperty({ example: 'john@example.com' })
@IsEmail()
email!: string;
@ApiProperty({ minLength: 8, example: 'StrongPass123!' })
@IsString()
@Length(8, 64)
password!: string;
@ApiProperty({ required: false, maxLength: 160 })
@IsOptional()
@IsString()
@Length(0, 160)
bio?: string;
@ApiProperty({ required: false, example: 'https://cdn.example.com/avatar.jpg' })
@IsOptional()
@IsUrl({ require_tld: false })
avatar?: string;
@ApiProperty({ required: false, example: 'Riyadh, Saudi Arabia' })
@IsOptional()
@IsString()
@Length(0, 120)
location?: string;
@ApiProperty({ required: false, example: 24.7136, minimum: -90, maximum: 90 })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number;
@ApiProperty({ required: false, example: 46.6753, minimum: -180, maximum: 180 })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number;
@ApiProperty({ required: false, default: false })
@IsOptional()
@IsBoolean()
isPrivate?: boolean;
@ApiProperty({ required: false, default: false })
@IsOptional()
@IsBoolean()
isVerified?: boolean;
@ApiProperty({ required: false, enum: MusicRole, isArray: true })
@IsOptional()
@IsArray()
@IsEnum(MusicRole, { each: true })
musicRoles?: MusicRole[];
@ApiProperty({ required: false, enum: ExperienceLevel, example: ExperienceLevel.BEGINNER })
@IsOptional()
@IsEnum(ExperienceLevel)
experienceLevel?: ExperienceLevel;
@ApiProperty({ required: false, type: [String], example: ['Tarab', 'Pop'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
musicGenres?: string[];
@ApiProperty({ required: false, type: [String], example: ['Oud', 'Piano'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
favoriteInstruments?: string[];
@ApiProperty({ required: false, type: [String], example: ['Bayati', 'Rast'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
favoriteMaqamat?: string[];
}

عرض الملف

@@ -0,0 +1,35 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsEnum, IsOptional, IsString } from 'class-validator';
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
import { MusicRole } from '../../../common/enums/music-role.enum';
export class MusicSetupDto {
@ApiPropertyOptional({ enum: MusicRole, isArray: true })
@IsOptional()
@IsArray()
@IsEnum(MusicRole, { each: true })
musicRoles?: MusicRole[];
@ApiPropertyOptional({ type: [String], example: ['Tarab', 'Pop'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
musicGenres?: string[];
@ApiPropertyOptional({ enum: ExperienceLevel, example: ExperienceLevel.BEGINNER })
@IsOptional()
@IsEnum(ExperienceLevel)
experienceLevel?: ExperienceLevel;
@ApiPropertyOptional({ type: [String], example: ['Oud', 'Piano'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
favoriteInstruments?: string[];
@ApiPropertyOptional({ type: [String], example: ['Bayati', 'Rast'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
favoriteMaqamat?: string[];
}

عرض الملف

@@ -0,0 +1,44 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsNumber, IsOptional, IsString, IsUrl, Length, Max, Min } from 'class-validator';
export class ProfileSetupDto {
@ApiPropertyOptional({ example: 'Artist One' })
@IsOptional()
@IsString()
@Length(1, 80)
stageName?: string;
@ApiPropertyOptional({ example: 'https://cdn.example.com/avatar.jpg' })
@IsOptional()
@IsUrl({ require_tld: false })
avatar?: string;
@ApiPropertyOptional({ example: '<27><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD>', maxLength: 150 })
@IsOptional()
@IsString()
@Length(0, 150)
bio?: string;
@ApiPropertyOptional({ example: 'Riyadh, Saudi Arabia' })
@IsOptional()
@IsString()
@Length(0, 120)
location?: string;
@ApiProperty({ example: 24.7136, minimum: -90, maximum: 90 })
@Transform(({ value }) => (typeof value === 'string' ? Number.parseFloat(value) : value))
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
latitude!: number;
@ApiProperty({ example: 46.6753, minimum: -180, maximum: 180 })
@Transform(({ value }) => (typeof value === 'string' ? Number.parseFloat(value) : value))
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
longitude!: number;
}

عرض الملف

@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { UserRole } from '../../../common/enums/user-role.enum';
export class UpdateUserRoleDto {
@IsEnum(UserRole)
role!: UserRole;
}

عرض الملف

@@ -0,0 +1,69 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator';
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
import { MusicRole } from '../../../common/enums/music-role.enum';
export class UpdateUserDto {
@ApiPropertyOptional({ example: 'John Doe' })
@IsOptional()
@IsString()
@Length(2, 80)
name?: string;
@ApiPropertyOptional({ example: 'Artist One' })
@IsOptional()
@IsString()
@Length(0, 80)
stageName?: string;
@ApiPropertyOptional({ maxLength: 160 })
@IsOptional()
@IsString()
@Length(0, 160)
bio?: string;
@ApiPropertyOptional({ example: 'https://cdn.example.com/avatar.jpg' })
@IsOptional()
@IsUrl({ require_tld: false })
avatar?: string;
@ApiPropertyOptional({ example: 'Riyadh, Saudi Arabia' })
@IsOptional()
@IsString()
@Length(0, 120)
location?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isPrivate?: boolean;
@ApiPropertyOptional({ enum: MusicRole, isArray: true })
@IsOptional()
@IsArray()
@IsEnum(MusicRole, { each: true })
musicRoles?: MusicRole[];
@ApiPropertyOptional({ enum: ExperienceLevel })
@IsOptional()
@IsEnum(ExperienceLevel)
experienceLevel?: ExperienceLevel;
@ApiPropertyOptional({ type: [String], example: ['Tarab', 'Pop'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
musicGenres?: string[];
@ApiPropertyOptional({ type: [String], example: ['Oud', 'Piano'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
favoriteInstruments?: string[];
@ApiPropertyOptional({ type: [String], example: ['Bayati', 'Rast'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
favoriteMaqamat?: string[];
}

عرض الملف

@@ -0,0 +1,15 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
export class UserQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Search by name or username' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isVerified?: boolean;
}

عرض الملف

@@ -0,0 +1,124 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
import { MusicRole } from '../../../common/enums/music-role.enum';
import { UserRole } from '../../../common/enums/user-role.enum';
export type UserDocument = HydratedDocument<User>;
@Schema({ timestamps: true, versionKey: false })
export class User {
@Prop({ required: true, trim: true, minlength: 2, maxlength: 80 })
name!: string;
@Prop({ default: '', trim: true, maxlength: 80, index: true })
stageName!: string;
@Prop({ required: true, trim: true, lowercase: true, unique: true, index: true, minlength: 3, maxlength: 30 })
username!: string;
@Prop({ required: true, trim: true, lowercase: true, unique: true, index: true })
email!: string;
@Prop({ required: true, minlength: 8, select: false })
password!: string;
@Prop({ type: String, required: false, unique: true, sparse: true, index: true })
googleId?: string;
@Prop({ default: 'local', enum: ['local', 'google'], index: true })
authProvider!: 'local' | 'google';
@Prop({ type: String, enum: Object.values(UserRole), default: UserRole.USER, index: true })
role!: UserRole;
@Prop({ default: false, index: true })
isDisabled!: boolean;
@Prop({ type: Date, required: false })
disabledAt?: Date;
@Prop({ default: '', maxlength: 300 })
disabledReason!: string;
@Prop({ type: String, required: false, index: true })
disabledBy?: string;
@Prop({ default: '', maxlength: 160 })
bio!: string;
@Prop({ default: '' })
avatar!: string;
@Prop({ default: '' })
location!: string;
@Prop({ type: Number, min: -90, max: 90, default: null })
latitude!: number | null;
@Prop({ type: Number, min: -180, max: 180, default: null })
longitude!: number | null;
@Prop({ type: [String], enum: Object.values(MusicRole), default: [] })
musicRoles!: MusicRole[];
@Prop({ type: String, enum: Object.values(ExperienceLevel), default: ExperienceLevel.BEGINNER })
experienceLevel!: ExperienceLevel;
@Prop({ type: [String], default: [] })
musicGenres!: string[];
@Prop({ type: [String], default: [] })
favoriteInstruments!: string[];
@Prop({ type: [String], default: [] })
favoriteMaqamat!: string[];
@Prop({ default: 0, min: 0 })
followersCount!: number;
@Prop({ default: 0, min: 0 })
followingCount!: number;
@Prop({ default: 0, min: 0 })
postsCount!: number;
@Prop({ default: false })
isPrivate!: boolean;
@Prop({ default: false, index: true })
isVerified!: boolean;
}
export const UserSchema = SchemaFactory.createForClass(User);
UserSchema.index({ createdAt: -1 });
const resolveAvatarUrl = (avatar: unknown): unknown => {
if (typeof avatar !== 'string' || !avatar.trim()) {
return avatar;
}
if (!avatar.startsWith('/uploads/')) {
return avatar;
}
const baseUrl = (process.env.PUBLIC_BASE_URL ?? '').replace(/\/$/, '');
if (!baseUrl) {
return avatar;
}
return `${baseUrl}${avatar}`;
};
const stripLegacyRoleFlags = (_doc: unknown, ret: any) => {
delete ret.isInstrumentalist;
delete ret.isSinger;
delete ret.isComposer;
delete ret.isLyricist;
ret.avatar = resolveAvatarUrl(ret.avatar);
return ret;
};
UserSchema.set('toJSON', { transform: stripLegacyRoleFlags });
UserSchema.set('toObject', { transform: stripLegacyRoleFlags });

عرض الملف

@@ -0,0 +1,188 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { AdminDisableUserDto } from './dto/admin-disable-user.dto';
import { CreateAdminDto } from './dto/create-admin.dto';
import { MusicSetupDto } from './dto/music-setup.dto';
import { ProfileSetupDto } from './dto/profile-setup.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserQueryDto } from './dto/user-query.dto';
import { UsersService } from './users.service';
@ApiTags('Users')
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch('me/profile-setup')
@UseInterceptors(FileInterceptor('avatarFile'))
@ApiConsumes('application/json', 'multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
stageName: { type: 'string', example: 'Artist One' },
bio: { type: 'string', example: 'Short bio' },
location: { type: 'string', example: 'Riyadh, Saudi Arabia' },
latitude: { type: 'number', example: 24.7136 },
longitude: { type: 'number', example: 46.6753 },
avatar: { type: 'string', example: 'https://cdn.example.com/avatar.jpg' },
avatarFile: { type: 'string', format: 'binary' },
},
required: ['latitude', 'longitude'],
},
})
async updateProfileSetup(
@CurrentUser() user: JwtPayload,
@Body() dto: ProfileSetupDto,
@UploadedFile()
avatarFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
) {
return this.usersService.updateProfileSetup(user.sub, dto, avatarFile);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch('me/music-setup')
async updateMusicSetup(@CurrentUser() user: JwtPayload, @Body() dto: MusicSetupDto) {
return this.usersService.updateMusicSetup(user.sub, dto);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch('me')
async updateMe(@CurrentUser() user: JwtPayload, @Body() dto: UpdateUserDto) {
return this.usersService.updateProfile(user.sub, dto);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Post('admin/create-admin')
async createAdmin(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateAdminDto,
) {
return this.usersService.createAdminBySuperAdmin(user.email ?? user.sub, dto);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Get('admin')
async adminFindMany(@Query() query: UserQueryDto) {
return this.usersService.searchUsersForSuperAdmin(query);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Get('admin/admins')
async adminListAdmins(@Query() query: UserQueryDto) {
return this.usersService.listAdminsBySuperAdmin(query);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Get('admin/:id')
async adminFindOne(@Param('id') targetUserId: string) {
return this.usersService.findUserByIdForSuperAdmin(targetUserId);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Patch('admin/:id')
async adminUpdateUser(
@CurrentUser() user: JwtPayload,
@Param('id') targetUserId: string,
@Body() dto: UpdateUserDto,
) {
return this.usersService.updateUserBySuperAdmin(user.email ?? user.sub, targetUserId, dto);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Patch('admin/:id/role')
async adminUpdateUserRole(
@CurrentUser() user: JwtPayload,
@Param('id') targetUserId: string,
@Body() dto: UpdateUserRoleDto,
) {
return this.usersService.updateUserRoleBySuperAdmin(user.email ?? user.sub, targetUserId, dto);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Patch('admin/:id/disable')
async disableUser(
@CurrentUser() user: JwtPayload,
@Param('id') targetUserId: string,
@Body() dto: AdminDisableUserDto,
) {
return this.usersService.disableUserBySuperAdmin(user.email ?? user.sub, targetUserId, dto);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Patch('admin/:id/enable')
async enableUser(@CurrentUser() user: JwtPayload, @Param('id') targetUserId: string) {
return this.usersService.enableUserBySuperAdmin(user.email ?? user.sub, targetUserId);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Delete('admin/:id')
async deleteUser(@CurrentUser() user: JwtPayload, @Param('id') targetUserId: string) {
await this.usersService.deleteUserBySuperAdmin(user.email ?? user.sub, targetUserId);
return { message: 'User deleted successfully' };
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Patch('admin/admins/:id')
async adminUpdateAdmin(
@CurrentUser() user: JwtPayload,
@Param('id') targetUserId: string,
@Body() dto: UpdateUserDto,
) {
return this.usersService.updateAdminBySuperAdmin(user.email ?? user.sub, targetUserId, dto);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Delete('admin/admins/:id')
async adminDeleteAdmin(@CurrentUser() user: JwtPayload, @Param('id') targetUserId: string) {
await this.usersService.deleteAdminBySuperAdmin(user.email ?? user.sub, targetUserId);
return { message: 'Admin deleted successfully' };
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':id')
async findOne(@Param('id') id: string) {
return this.usersService.findByIdOrFail(id);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get()
async findMany(@Query() query: UserQueryDto) {
return this.usersService.searchUsers(query);
}
}

عرض الملف

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

عرض الملف

@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { ClientSession, FilterQuery, Model, UpdateQuery } from 'mongoose';
import { User, UserDocument } from './schemas/user.schema';
@Injectable()
export class UsersRepository {
constructor(@InjectModel(User.name) private readonly userModel: Model<UserDocument>) {}
async create(payload: Partial<User>): Promise<UserDocument> {
return this.userModel.create(payload);
}
async findById(id: string): Promise<UserDocument | null> {
return this.userModel.findById(id).exec();
}
async findByIdWithPassword(id: string): Promise<UserDocument | null> {
return this.userModel.findById(id).select('+password').exec();
}
async findOne(filter: FilterQuery<UserDocument>): Promise<UserDocument | null> {
return this.userModel.findOne(filter).exec();
}
async findOneWithPassword(filter: FilterQuery<UserDocument>): Promise<UserDocument | null> {
return this.userModel.findOne(filter).select('+password').exec();
}
async updateById(id: string, payload: UpdateQuery<UserDocument>): Promise<UserDocument | null> {
return this.userModel.findByIdAndUpdate(id, payload, { new: true }).exec();
}
async deleteById(id: string): Promise<UserDocument | null> {
return this.userModel.findByIdAndDelete(id).exec();
}
async incrementPostsCount(userId: string, delta: 1 | -1, session?: ClientSession): Promise<void> {
await this.userModel
.findByIdAndUpdate(userId, { $inc: { postsCount: delta } }, { new: false, session })
.exec();
}
async incrementFollowersCount(
userId: string,
delta: 1 | -1,
session?: ClientSession,
): Promise<void> {
await this.userModel
.findByIdAndUpdate(userId, { $inc: { followersCount: delta } }, { new: false, session })
.exec();
}
async incrementFollowingCount(
userId: string,
delta: 1 | -1,
session?: ClientSession,
): Promise<void> {
await this.userModel
.findByIdAndUpdate(userId, { $inc: { followingCount: delta } }, { new: false, session })
.exec();
}
async setFollowersCount(userId: string, followersCount: number): Promise<void> {
await this.userModel.findByIdAndUpdate(userId, { followersCount }, { new: false }).exec();
}
async setFollowingCount(userId: string, followingCount: number): Promise<void> {
await this.userModel.findByIdAndUpdate(userId, { followingCount }, { new: false }).exec();
}
async findMany(filter: FilterQuery<UserDocument>, skip: number, limit: number): Promise<UserDocument[]> {
return this.userModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).exec();
}
async count(filter: FilterQuery<UserDocument>): Promise<number> {
return this.userModel.countDocuments(filter).exec();
}
async findSuggestionCandidates(
filter: FilterQuery<UserDocument>,
limit: number,
): Promise<UserDocument[]> {
return this.userModel
.find(filter)
.sort({ isVerified: -1, followersCount: -1, createdAt: -1 })
.limit(limit)
.exec();
}
}

عرض الملف

@@ -0,0 +1,649 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';
import { mkdir, unlink, writeFile } from 'fs/promises';
import { extname, join } from 'path';
import { Connection, Types } from 'mongoose';
import { ExperienceLevel } from '../../common/enums/experience-level.enum';
import { hashValue } from '../../common/utils/hash.util';
import { AuditService } from '../audit/audit.service';
import { UserRole } from '../../common/enums/user-role.enum';
import { CreateUserDto } from './dto/create-user.dto';
import { AdminDisableUserDto } from './dto/admin-disable-user.dto';
import { CreateAdminDto } from './dto/create-admin.dto';
import { MusicSetupDto } from './dto/music-setup.dto';
import { ProfileSetupDto } from './dto/profile-setup.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserQueryDto } from './dto/user-query.dto';
import { UsersRepository } from './users.repository';
import { UserDocument } from './schemas/user.schema';
@Injectable()
export class UsersService {
constructor(
@InjectConnection() private readonly connection: Connection,
private readonly usersRepository: UsersRepository,
private readonly auditService: AuditService,
private readonly configService: ConfigService,
) {}
async create(dto: CreateUserDto & { password: string; role?: UserRole }): Promise<UserDocument> {
const existing = await this.usersRepository.findOne({
$or: [{ email: dto.email.toLowerCase() }, { username: dto.username.toLowerCase() }],
});
if (existing) {
throw new BadRequestException('Email or username already exists');
}
const roles = dto.musicRoles ?? [];
return this.usersRepository.create({
...dto,
email: dto.email.toLowerCase(),
username: dto.username.toLowerCase(),
stageName: dto.stageName ?? '',
bio: dto.bio ?? '',
avatar: dto.avatar ?? '',
location: dto.location ?? '',
latitude: dto.latitude,
longitude: dto.longitude,
isPrivate: dto.isPrivate ?? false,
isVerified: dto.isVerified ?? false,
musicRoles: roles,
experienceLevel: dto.experienceLevel ?? ExperienceLevel.BEGINNER,
musicGenres: dto.musicGenres ?? [],
favoriteInstruments: dto.favoriteInstruments ?? [],
favoriteMaqamat: dto.favoriteMaqamat ?? [],
role: dto.role ?? UserRole.USER,
isDisabled: false,
disabledReason: '',
authProvider: 'local',
});
}
async createAdminBySuperAdmin(
superAdminIdentifier: string,
dto: CreateAdminDto,
): Promise<UserDocument> {
if (dto.password !== dto.confirmPassword) {
throw new BadRequestException('Password confirmation does not match');
}
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
const passwordHash = await hashValue(dto.password, saltRounds);
const admin = await this.create({
name: dto.name,
username: dto.username,
email: dto.email,
password: passwordHash,
role: UserRole.ADMIN,
isVerified: true,
});
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'admin_create',
'user',
admin.id,
{ role: UserRole.ADMIN },
);
return admin;
}
async listAdminsBySuperAdmin(query: UserQueryDto): Promise<{
items: UserDocument[];
page: number;
limit: number;
total: number;
totalPages: number;
}> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const filter: Record<string, unknown> = {
role: UserRole.ADMIN,
};
if (query.q) {
filter.$or = [
{ name: { $regex: query.q, $options: 'i' } },
{ username: { $regex: query.q, $options: 'i' } },
{ email: { $regex: query.q, $options: 'i' } },
];
}
if (typeof query.isVerified === 'boolean') {
filter.isVerified = query.isVerified;
}
const [items, total] = await Promise.all([
this.usersRepository.findMany(filter, skip, limit),
this.usersRepository.count(filter),
]);
return {
items,
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
}
async updateAdminBySuperAdmin(
superAdminIdentifier: string,
adminUserId: string,
dto: UpdateUserDto,
): Promise<UserDocument> {
await this.assertTargetIsAdmin(adminUserId);
const updated = await this.usersRepository.updateById(adminUserId, dto);
if (!updated) {
throw new NotFoundException('Admin not found');
}
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'admin_update',
'user',
adminUserId,
{ fields: Object.keys(dto) },
);
return updated;
}
async deleteAdminBySuperAdmin(superAdminIdentifier: string, adminUserId: string): Promise<void> {
const admin = await this.assertTargetIsAdmin(adminUserId);
await this.deleteUserRelatedData(adminUserId, admin.avatar ?? '');
await this.usersRepository.deleteById(adminUserId);
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'admin_delete',
'user',
adminUserId,
);
}
async updateUserRoleBySuperAdmin(
superAdminIdentifier: string,
targetUserId: string,
dto: UpdateUserRoleDto,
): Promise<UserDocument> {
if (dto.role === UserRole.SUPERADMIN) {
throw new BadRequestException('Cannot assign superadmin role via API');
}
const updated = await this.usersRepository.updateById(targetUserId, { role: dto.role });
if (!updated) {
throw new NotFoundException('User not found');
}
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'user_role_update',
'user',
targetUserId,
{ role: dto.role },
);
return updated;
}
async findByIdOrFail(userId: string): Promise<UserDocument> {
const user = await this.usersRepository.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
async findByEmailWithPassword(email: string): Promise<UserDocument | null> {
return this.usersRepository.findOneWithPassword({ email: email.toLowerCase() });
}
async findByEmail(email: string): Promise<UserDocument | null> {
return this.usersRepository.findOne({ email: email.toLowerCase() });
}
async findByUsername(username: string): Promise<UserDocument | null> {
return this.usersRepository.findOne({ username: username.toLowerCase() });
}
async findByGoogleId(googleId: string): Promise<UserDocument | null> {
return this.usersRepository.findOne({ googleId });
}
async linkGoogleAccount(userId: string, googleId: string, avatar?: string): Promise<UserDocument> {
const user = await this.usersRepository.updateById(userId, {
googleId,
authProvider: 'google',
...(avatar ? { avatar } : {}),
});
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
async updateProfile(userId: string, dto: UpdateUserDto): Promise<UserDocument> {
const user = await this.usersRepository.updateById(userId, dto);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
async updatePassword(userId: string, passwordHash: string): Promise<void> {
const updated = await this.usersRepository.updateById(userId, { password: passwordHash });
if (!updated) {
throw new NotFoundException('User not found');
}
}
async markEmailVerified(userId: string): Promise<void> {
const updated = await this.usersRepository.updateById(userId, { isVerified: true });
if (!updated) {
throw new NotFoundException('User not found');
}
}
async findUserByIdForSuperAdmin(targetUserId: string): Promise<UserDocument> {
return this.findByIdOrFail(targetUserId);
}
async updateUserBySuperAdmin(
superAdminIdentifier: string,
targetUserId: string,
dto: UpdateUserDto,
): Promise<UserDocument> {
const user = await this.usersRepository.updateById(targetUserId, dto);
if (!user) {
throw new NotFoundException('User not found');
}
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'user_update',
'user',
targetUserId,
{ fields: Object.keys(dto) },
);
return user;
}
async updateProfileSetup(
userId: string,
dto: ProfileSetupDto,
avatarFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
): Promise<UserDocument> {
if (!Number.isFinite(dto.latitude) || !Number.isFinite(dto.longitude)) {
throw new BadRequestException('latitude and longitude are required');
}
const currentUser = await this.findByIdOrFail(userId);
const payload: Record<string, unknown> = { ...dto };
let uploadedAvatarUrl: string | null = null;
if (avatarFile) {
uploadedAvatarUrl = await this.saveAvatarFile(avatarFile);
payload.avatar = uploadedAvatarUrl;
}
const user = await this.usersRepository.updateById(userId, payload);
if (!user) {
if (uploadedAvatarUrl) {
await this.deleteManagedAvatar(uploadedAvatarUrl);
}
throw new NotFoundException('User not found');
}
const nextAvatar =
typeof payload.avatar === 'string' ? payload.avatar : currentUser.avatar;
if (nextAvatar !== currentUser.avatar) {
await this.deleteManagedAvatar(currentUser.avatar);
}
return user;
}
private async saveAvatarFile(
avatarFile: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
): Promise<string> {
const extension = this.resolveAvatarExtension(avatarFile);
const maxSize = 5 * 1024 * 1024;
if (!extension) {
throw new BadRequestException('avatarFile must be png, jpg, jpeg, webp, or gif');
}
if (avatarFile.size > maxSize) {
throw new BadRequestException('avatarFile size must be 5MB or less');
}
const uploadDir = join(process.cwd(), 'uploads', 'avatars');
const fileName = `${randomUUID()}${extension}`;
await mkdir(uploadDir, { recursive: true });
await writeFile(join(uploadDir, fileName), avatarFile.buffer);
return `/uploads/avatars/${encodeURIComponent(fileName)}`;
}
private resolveAvatarExtension(avatarFile: {
mimetype?: string;
originalname?: string;
}): string | null {
const originalExtension = extname(avatarFile.originalname ?? '').toLowerCase();
const allowedExtensions = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
if (allowedExtensions.has(originalExtension)) {
return originalExtension;
}
switch (avatarFile.mimetype) {
case 'image/png':
return '.png';
case 'image/jpeg':
case 'image/jpg':
return '.jpg';
case 'image/webp':
return '.webp';
case 'image/gif':
return '.gif';
default:
return null;
}
}
private async deleteManagedAvatar(avatarUrl?: string): Promise<void> {
const marker = '/uploads/avatars/';
const markerIndex = avatarUrl?.indexOf(marker) ?? -1;
if (markerIndex === -1) {
return;
}
const encodedFileName = avatarUrl!
.slice(markerIndex + marker.length)
.split('?')[0]
.split('#')[0];
if (!encodedFileName) {
return;
}
const fileName = decodeURIComponent(encodedFileName);
try {
await unlink(join(process.cwd(), 'uploads', 'avatars', fileName));
} catch {
// Ignore cleanup failures for already-missing files.
}
}
async updateMusicSetup(userId: string, dto: MusicSetupDto): Promise<UserDocument> {
const payload: Record<string, unknown> = { ...dto };
const user = await this.usersRepository.updateById(userId, payload);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
async disableUserBySuperAdmin(
superAdminIdentifier: string,
targetUserId: string,
dto: AdminDisableUserDto,
): Promise<UserDocument> {
await this.findByIdOrFail(targetUserId);
const updated = await this.usersRepository.updateById(targetUserId, {
isDisabled: true,
disabledAt: new Date(),
disabledReason: dto.reason ?? '',
disabledBy: superAdminIdentifier,
});
if (!updated) {
throw new NotFoundException('User not found');
}
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'user_disable',
'user',
targetUserId,
{ reason: dto.reason ?? '' },
);
return updated;
}
async enableUserBySuperAdmin(superAdminIdentifier: string, targetUserId: string): Promise<UserDocument> {
const updated = await this.usersRepository.updateById(targetUserId, {
isDisabled: false,
disabledAt: null,
disabledReason: '',
disabledBy: null,
});
if (!updated) {
throw new NotFoundException('User not found');
}
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'user_enable',
'user',
targetUserId,
);
return updated;
}
async deleteUserBySuperAdmin(superAdminIdentifier: string, targetUserId: string): Promise<void> {
const user = await this.findByIdOrFail(targetUserId);
await this.deleteUserRelatedData(targetUserId, user.avatar ?? '');
await this.usersRepository.deleteById(targetUserId);
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'user_delete',
'user',
targetUserId,
);
}
async searchUsers(query: UserQueryDto): Promise<{
items: UserDocument[];
page: number;
limit: number;
total: number;
totalPages: number;
}> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const filter: Record<string, unknown> = {};
if (query.q) {
filter.$or = [
{ name: { $regex: query.q, $options: 'i' } },
{ username: { $regex: query.q, $options: 'i' } },
];
}
if (typeof query.isVerified === 'boolean') {
filter.isVerified = query.isVerified;
}
const [items, total] = await Promise.all([
this.usersRepository.findMany(filter, skip, limit),
this.usersRepository.count(filter),
]);
return {
items,
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
}
async searchUsersForSuperAdmin(query: UserQueryDto): Promise<{
items: UserDocument[];
page: number;
limit: number;
total: number;
totalPages: number;
}> {
return this.searchUsers(query);
}
private async assertTargetIsAdmin(userId: string): Promise<UserDocument> {
const user = await this.findByIdOrFail(userId);
if (user.role !== UserRole.ADMIN) {
throw new BadRequestException('Target user is not an admin');
}
return user;
}
private async deleteUserRelatedData(userId: string, avatarUrl?: string): Promise<void> {
if (!Types.ObjectId.isValid(userId)) {
return;
}
const objectUserId = new Types.ObjectId(userId);
const postsCollection = this.connection.collection('posts');
const commentsCollection = this.connection.collection('comments');
const likesCollection = this.connection.collection('likes');
const savesCollection = this.connection.collection('saves');
const followsCollection = this.connection.collection('follows');
const notificationsCollection = this.connection.collection('notifications');
const conversationsCollection = this.connection.collection('conversations');
const messagesCollection = this.connection.collection('messages');
const chatBlocksCollection = this.connection.collection('chatblocks');
const marketplaceCollection = this.connection.collection('instruments');
const refreshTokensCollection = this.connection.collection('refreshtokens');
const passwordResetCodesCollection = this.connection.collection('passwordresetcodes');
const emailVerificationCodesCollection = this.connection.collection('emailverificationcodes');
const [adminPosts, ownComments, adminConversations, ownMessages, ownInstruments] = await Promise.all([
postsCollection.find({ authorId: objectUserId }).project({ _id: 1, videoUrl: 1, audioUrl: 1 }).toArray(),
commentsCollection.find({ authorId: objectUserId }).project({ _id: 1 }).toArray(),
conversationsCollection.find({ participantIds: objectUserId }).project({ _id: 1 }).toArray(),
messagesCollection.find({ senderId: objectUserId }).project({ _id: 1, mediaUrl: 1 }).toArray(),
marketplaceCollection.find({ ownerAdminId: objectUserId }).project({ _id: 1, imageUrls: 1 }).toArray(),
]);
const postIds = adminPosts.map((post) => post._id as Types.ObjectId);
const ownCommentIds = ownComments.map((comment) => comment._id as Types.ObjectId);
const conversationIds = adminConversations.map((conversation) => conversation._id as Types.ObjectId);
const postComments = postIds.length
? await commentsCollection.find({ postId: { $in: postIds } }).project({ _id: 1 }).toArray()
: [];
const postCommentIds = postComments.map((comment) => comment._id as Types.ObjectId);
const commentIds = [...ownCommentIds, ...postCommentIds];
const conversationMessages = conversationIds.length
? await messagesCollection
.find({ conversationId: { $in: conversationIds } })
.project({ _id: 1, mediaUrl: 1 })
.toArray()
: [];
const localFilesToDelete = new Set<string>();
if (avatarUrl) {
localFilesToDelete.add(avatarUrl);
}
for (const post of adminPosts) {
if (typeof post.videoUrl === 'string') {
localFilesToDelete.add(post.videoUrl);
}
if (typeof post.audioUrl === 'string') {
localFilesToDelete.add(post.audioUrl);
}
}
for (const message of [...ownMessages, ...conversationMessages]) {
if (typeof message.mediaUrl === 'string') {
localFilesToDelete.add(message.mediaUrl);
}
}
for (const instrument of ownInstruments) {
if (Array.isArray(instrument.imageUrls)) {
for (const imageUrl of instrument.imageUrls) {
if (typeof imageUrl === 'string') {
localFilesToDelete.add(imageUrl);
}
}
}
}
const likesDeleteOr: Record<string, unknown>[] = [{ userId: objectUserId }];
if (postIds.length) {
likesDeleteOr.push({ targetType: 'post', targetId: { $in: postIds } });
}
if (commentIds.length) {
likesDeleteOr.push({ targetType: 'comment', targetId: { $in: commentIds } });
}
const savesDeleteOr: Record<string, unknown>[] = [{ userId: objectUserId }];
if (postIds.length) {
savesDeleteOr.push({ postId: { $in: postIds } });
}
await Promise.all([
likesCollection.deleteMany({ $or: likesDeleteOr }),
savesCollection.deleteMany({ $or: savesDeleteOr }),
followsCollection.deleteMany({ $or: [{ followerId: objectUserId }, { followingId: objectUserId }] }),
notificationsCollection.deleteMany({ $or: [{ recipientId: objectUserId }, { actorId: objectUserId }] }),
chatBlocksCollection.deleteMany({ $or: [{ blockerId: objectUserId }, { blockedId: objectUserId }] }),
refreshTokensCollection.deleteMany({ userId: objectUserId }),
passwordResetCodesCollection.deleteMany({ userId: objectUserId }),
emailVerificationCodesCollection.deleteMany({ userId: objectUserId }),
conversationIds.length ? messagesCollection.deleteMany({ conversationId: { $in: conversationIds } }) : null,
ownMessages.length ? messagesCollection.deleteMany({ senderId: objectUserId }) : null,
conversationIds.length ? conversationsCollection.deleteMany({ _id: { $in: conversationIds } }) : null,
commentIds.length ? commentsCollection.deleteMany({ _id: { $in: commentIds } }) : null,
postIds.length ? postsCollection.deleteMany({ _id: { $in: postIds } }) : null,
marketplaceCollection.deleteMany({ ownerAdminId: objectUserId }),
]);
await Promise.all([...localFilesToDelete].map((fileUrl) => this.deleteManagedUpload(fileUrl)));
}
private async deleteManagedUpload(fileUrl: string): Promise<void> {
if (!fileUrl?.startsWith('/uploads/')) {
return;
}
const relativePath = fileUrl.split('?')[0].split('#')[0].replace(/^\/+/, '');
const normalizedPath = relativePath.replace(/\//g, '\\');
if (normalizedPath.includes('..')) {
return;
}
try {
await unlink(join(process.cwd(), normalizedPath));
} catch {
// Ignore cleanup failures for already-missing files.
}
}
}