first commit
هذا الالتزام موجود في:
10
src/modules/users/dto/admin-disable-user.dto.ts
Normal file
10
src/modules/users/dto/admin-disable-user.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
27
src/modules/users/dto/create-admin.dto.ts
Normal file
27
src/modules/users/dto/create-admin.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
115
src/modules/users/dto/create-user.dto.ts
Normal file
115
src/modules/users/dto/create-user.dto.ts
Normal file
@@ -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[];
|
||||
}
|
||||
35
src/modules/users/dto/music-setup.dto.ts
Normal file
35
src/modules/users/dto/music-setup.dto.ts
Normal file
@@ -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[];
|
||||
}
|
||||
44
src/modules/users/dto/profile-setup.dto.ts
Normal file
44
src/modules/users/dto/profile-setup.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
7
src/modules/users/dto/update-user-role.dto.ts
Normal file
7
src/modules/users/dto/update-user-role.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { UserRole } from '../../../common/enums/user-role.enum';
|
||||
|
||||
export class UpdateUserRoleDto {
|
||||
@IsEnum(UserRole)
|
||||
role!: UserRole;
|
||||
}
|
||||
69
src/modules/users/dto/update-user.dto.ts
Normal file
69
src/modules/users/dto/update-user.dto.ts
Normal file
@@ -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[];
|
||||
}
|
||||
15
src/modules/users/dto/user-query.dto.ts
Normal file
15
src/modules/users/dto/user-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
124
src/modules/users/schemas/user.schema.ts
Normal file
124
src/modules/users/schemas/user.schema.ts
Normal file
@@ -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 });
|
||||
188
src/modules/users/users.controller.ts
Normal file
188
src/modules/users/users.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
23
src/modules/users/users.module.ts
Normal file
23
src/modules/users/users.module.ts
Normal file
@@ -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 {}
|
||||
90
src/modules/users/users.repository.ts
Normal file
90
src/modules/users/users.repository.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
649
src/modules/users/users.service.ts
Normal file
649
src/modules/users/users.service.ts
Normal file
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم