feat: expand backend admin marketplace and scaling
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
هذا الالتزام موجود في:
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from 'class-validator';
|
||||
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
|
||||
import { MusicRole } from '../../../common/enums/music-role.enum';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({ example: 'John Doe' })
|
||||
@@ -54,6 +56,11 @@ export class CreateUserDto {
|
||||
@IsUrl({ require_tld: false })
|
||||
avatar?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 'https://cdn.example.com/profile-cover.jpg' })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false })
|
||||
coverImage?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 'Riyadh, Saudi Arabia' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -76,11 +83,13 @@ export class CreateUserDto {
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isPrivate?: boolean;
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isVerified?: boolean;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsNumber, IsOptional, IsString, IsUrl, Length, Max, Min } from 'class-validator';
|
||||
|
||||
@@ -14,7 +14,12 @@ export class ProfileSetupDto {
|
||||
@IsUrl({ require_tld: false })
|
||||
avatar?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '<EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD>', maxLength: 150 })
|
||||
@ApiPropertyOptional({ example: 'https://cdn.example.com/profile-cover.jpg' })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false })
|
||||
coverImage?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Short bio about me', maxLength: 150 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 150)
|
||||
|
||||
27
src/modules/users/dto/talent-discover-query.dto.ts
Normal file
27
src/modules/users/dto/talent-discover-query.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
import { UserQueryDto } from './user-query.dto';
|
||||
|
||||
export class TalentDiscoverQueryDto extends UserQueryDto {
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
hasAvatarOnly?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
includeRoleBuckets?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 24, default: 8 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(24)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -1,7 +1,22 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Length,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
|
||||
import { MusicRole } from '../../../common/enums/music-role.enum';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiPropertyOptional({ example: 'John Doe' })
|
||||
@@ -10,6 +25,18 @@ export class UpdateUserDto {
|
||||
@Length(2, 80)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'artist_one' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(3, 30)
|
||||
@Matches(/^[a-zA-Z0-9_.]+$/, { message: 'username can contain letters, numbers, _ and .' })
|
||||
username?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'artist@example.com' })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Artist One' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -27,17 +54,43 @@ export class UpdateUserDto {
|
||||
@IsUrl({ require_tld: false })
|
||||
avatar?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://cdn.example.com/profile-cover.jpg' })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false })
|
||||
coverImage?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Riyadh, Saudi Arabia' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 120)
|
||||
location?: string;
|
||||
|
||||
@ApiPropertyOptional({ required: false, example: 24.7136, minimum: -90, maximum: 90 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude?: number;
|
||||
|
||||
@ApiPropertyOptional({ required: false, example: 46.6753, minimum: -180, maximum: 180 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isPrivate?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isVerified?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: MusicRole, isArray: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
|
||||
import { MusicRole } from '../../../common/enums/music-role.enum';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
|
||||
export const USER_SORT_FIELDS = [
|
||||
'createdAt',
|
||||
'name',
|
||||
'username',
|
||||
'followersCount',
|
||||
'postsCount',
|
||||
] as const;
|
||||
|
||||
export type UserSortField = (typeof USER_SORT_FIELDS)[number];
|
||||
|
||||
export class UserQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Search by name or username' })
|
||||
@@ -10,6 +24,34 @@ export class UserQueryDto extends PaginationQueryDto {
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isVerified?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: MusicRole })
|
||||
@IsOptional()
|
||||
@IsEnum(MusicRole)
|
||||
musicRole?: MusicRole;
|
||||
|
||||
@ApiPropertyOptional({ enum: ExperienceLevel })
|
||||
@IsOptional()
|
||||
@IsEnum(ExperienceLevel)
|
||||
experienceLevel?: ExperienceLevel;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isPrivate?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Require or exclude users with avatar images' })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
hasAvatar?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: USER_SORT_FIELDS, default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsEnum(USER_SORT_FIELDS)
|
||||
sortBy?: UserSortField;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ 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';
|
||||
import {
|
||||
resolveManagedFileUrl,
|
||||
resolveManagedFileUrls,
|
||||
} from '../../../common/utils/public-url.util';
|
||||
|
||||
export type UserDocument = HydratedDocument<User>;
|
||||
|
||||
@@ -50,6 +54,9 @@ export class User {
|
||||
@Prop({ default: '' })
|
||||
avatar!: string;
|
||||
|
||||
@Prop({ default: '' })
|
||||
coverImage!: string;
|
||||
|
||||
@Prop({ default: '' })
|
||||
location!: string;
|
||||
|
||||
@@ -88,35 +95,38 @@ export class User {
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
isVerified!: boolean;
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 120 })
|
||||
shopName!: string;
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 2000 })
|
||||
shopDescription!: string;
|
||||
|
||||
@Prop({ type: [String], default: [] })
|
||||
shopImageUrls!: string[];
|
||||
|
||||
@Prop({ default: '', trim: true, maxlength: 160 })
|
||||
shopLocation!: string;
|
||||
|
||||
@Prop({ type: Number, min: -90, max: 90, default: null })
|
||||
shopLatitude!: number | null;
|
||||
|
||||
@Prop({ type: Number, min: -180, max: 180, default: null })
|
||||
shopLongitude!: number | null;
|
||||
}
|
||||
|
||||
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);
|
||||
ret.avatar = resolveManagedFileUrl(ret.avatar);
|
||||
ret.coverImage = resolveManagedFileUrl(ret.coverImage);
|
||||
ret.shopImageUrls = resolveManagedFileUrls(ret.shopImageUrls);
|
||||
return ret;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,24 +7,28 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { FileFieldsInterceptor } 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 { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.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 { TalentDiscoverQueryDto } from './dto/talent-discover-query.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';
|
||||
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
|
||||
@ApiTags('Users')
|
||||
@Controller('users')
|
||||
@@ -34,8 +38,13 @@ export class UsersController {
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('me/profile-setup')
|
||||
@UseInterceptors(FileInterceptor('avatarFile'))
|
||||
@ApiConsumes('application/json', 'multipart/form-data')
|
||||
@UseInterceptors(
|
||||
FileFieldsInterceptor([
|
||||
{ name: 'avatarFile', maxCount: 1 },
|
||||
{ name: 'coverImageFile', maxCount: 1 },
|
||||
]),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
@@ -46,7 +55,9 @@ export class UsersController {
|
||||
latitude: { type: 'number', example: 24.7136 },
|
||||
longitude: { type: 'number', example: 46.6753 },
|
||||
avatar: { type: 'string', example: 'https://cdn.example.com/avatar.jpg' },
|
||||
coverImage: { type: 'string', example: 'https://cdn.example.com/profile-cover.jpg' },
|
||||
avatarFile: { type: 'string', format: 'binary' },
|
||||
coverImageFile: { type: 'string', format: 'binary' },
|
||||
},
|
||||
required: ['latitude', 'longitude'],
|
||||
},
|
||||
@@ -54,10 +65,28 @@ export class UsersController {
|
||||
async updateProfileSetup(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: ProfileSetupDto,
|
||||
@UploadedFile()
|
||||
avatarFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
avatarFile?: Array<{
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
}>;
|
||||
coverImageFile?: Array<{
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
}>;
|
||||
},
|
||||
) {
|
||||
return this.usersService.updateProfileSetup(user.sub, dto, avatarFile);
|
||||
return this.usersService.updateProfileSetup(
|
||||
user.sub,
|
||||
dto,
|
||||
files?.avatarFile?.[0],
|
||||
files?.coverImageFile?.[0],
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@@ -70,12 +99,59 @@ export class UsersController {
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('me')
|
||||
async updateMe(@CurrentUser() user: JwtPayload, @Body() dto: UpdateUserDto) {
|
||||
return this.usersService.updateProfile(user.sub, dto);
|
||||
@UseInterceptors(
|
||||
FileFieldsInterceptor([
|
||||
{ name: 'avatarFile', maxCount: 1 },
|
||||
{ name: 'coverImageFile', maxCount: 1 },
|
||||
]),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', example: 'Raffi Wadeea' },
|
||||
stageName: { type: 'string', example: 'Artist One' },
|
||||
bio: { type: 'string', example: 'Short bio' },
|
||||
avatar: { type: 'string', example: 'https://cdn.example.com/avatar.jpg' },
|
||||
coverImage: { type: 'string', example: 'https://cdn.example.com/profile-cover.jpg' },
|
||||
location: { type: 'string', example: 'Riyadh, Saudi Arabia' },
|
||||
isPrivate: { type: 'boolean', example: false },
|
||||
avatarFile: { type: 'string', format: 'binary' },
|
||||
coverImageFile: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async updateMe(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpdateUserDto,
|
||||
@UploadedFiles()
|
||||
files?: {
|
||||
avatarFile?: Array<{
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
}>;
|
||||
coverImageFile?: Array<{
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
}>;
|
||||
},
|
||||
) {
|
||||
return this.usersService.updateProfile(
|
||||
user.sub,
|
||||
dto,
|
||||
files?.avatarFile?.[0],
|
||||
files?.coverImageFile?.[0],
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard)
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE)
|
||||
@Post('admin/create-admin')
|
||||
async createAdmin(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@@ -85,28 +161,32 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard)
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_READ)
|
||||
@Get('admin')
|
||||
async adminFindMany(@Query() query: UserQueryDto) {
|
||||
return this.usersService.searchUsersForSuperAdmin(query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard)
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_READ)
|
||||
@Get('admin/admins')
|
||||
async adminListAdmins(@Query() query: UserQueryDto) {
|
||||
return this.usersService.listAdminsBySuperAdmin(query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard)
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_READ)
|
||||
@Get('admin/:id')
|
||||
async adminFindOne(@Param('id') targetUserId: string) {
|
||||
return this.usersService.findUserByIdForSuperAdmin(targetUserId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard)
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE)
|
||||
@Patch('admin/:id')
|
||||
async adminUpdateUser(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@@ -117,7 +197,8 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard)
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE)
|
||||
@Patch('admin/:id/role')
|
||||
async adminUpdateUserRole(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@@ -128,7 +209,8 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard)
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE)
|
||||
@Patch('admin/:id/disable')
|
||||
async disableUser(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@@ -139,14 +221,16 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard)
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE)
|
||||
@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)
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE)
|
||||
@Delete('admin/:id')
|
||||
async deleteUser(@CurrentUser() user: JwtPayload, @Param('id') targetUserId: string) {
|
||||
await this.usersService.deleteUserBySuperAdmin(user.email ?? user.sub, targetUserId);
|
||||
@@ -154,7 +238,8 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard)
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE)
|
||||
@Patch('admin/admins/:id')
|
||||
async adminUpdateAdmin(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@@ -165,18 +250,49 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard)
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE)
|
||||
@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(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_READ)
|
||||
@Get('admin/discover')
|
||||
async adminDiscoverTalents(@Query() query: TalentDiscoverQueryDto) {
|
||||
return this.usersService.discoverTalentsForSuperAdmin(query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_READ)
|
||||
@Get('admin/:id/profile-overview')
|
||||
async adminGetProfileOverview(@Param('id') id: string) {
|
||||
return this.usersService.getProfileOverviewForSuperAdmin(id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('discover')
|
||||
async discoverTalents(@Query() query: TalentDiscoverQueryDto) {
|
||||
return this.usersService.discoverTalents(query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get(':id/profile-overview')
|
||||
async getProfileOverview(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
|
||||
return this.usersService.getProfileOverview(id, user.sub);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.usersService.findByIdOrFail(id);
|
||||
return this.usersService.findPublicByIdOrFail(id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -69,8 +69,22 @@ export class UsersRepository {
|
||||
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 findMany(
|
||||
filter: FilterQuery<UserDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
sort: Record<string, 1 | -1> = { createdAt: -1 },
|
||||
): Promise<UserDocument[]> {
|
||||
return this.userModel.find(filter).sort(sort).skip(skip).limit(limit).exec();
|
||||
}
|
||||
|
||||
async findByUsernames(usernames: string[]): Promise<UserDocument[]> {
|
||||
if (!usernames.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized = Array.from(new Set(usernames.map((username) => username.toLowerCase())));
|
||||
return this.userModel.find({ username: { $in: normalized } }).exec();
|
||||
}
|
||||
|
||||
async count(filter: FilterQuery<UserDocument>): Promise<number> {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
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 { extname } from 'path';
|
||||
import { Connection, Types } from 'mongoose';
|
||||
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
||||
import { ExperienceLevel } from '../../common/enums/experience-level.enum';
|
||||
import { MusicRole } from '../../common/enums/music-role.enum';
|
||||
import { hashValue } from '../../common/utils/hash.util';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { UserRole } from '../../common/enums/user-role.enum';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
@@ -18,12 +22,20 @@ 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 { TalentDiscoverQueryDto } from './dto/talent-discover-query.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';
|
||||
|
||||
type UploadedImageFile = {
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@@ -31,6 +43,7 @@ export class UsersService {
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly storageService: ManagedStorageService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateUserDto & { password: string; role?: UserRole }): Promise<UserDocument> {
|
||||
@@ -51,6 +64,7 @@ export class UsersService {
|
||||
stageName: dto.stageName ?? '',
|
||||
bio: dto.bio ?? '',
|
||||
avatar: dto.avatar ?? '',
|
||||
coverImage: dto.coverImage ?? '',
|
||||
location: dto.location ?? '',
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
@@ -126,18 +140,21 @@ export class UsersService {
|
||||
filter.isVerified = query.isVerified;
|
||||
}
|
||||
|
||||
const direction = resolveMongoSortDirection(query.sortOrder);
|
||||
const sortField = query.sortBy ?? 'createdAt';
|
||||
const sort = { [sortField]: direction } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.usersRepository.findMany(filter, skip, limit),
|
||||
this.usersRepository.findMany(filter, skip, limit, sort),
|
||||
this.usersRepository.count(filter),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
return buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
offset: skip,
|
||||
});
|
||||
}
|
||||
|
||||
async updateAdminBySuperAdmin(
|
||||
@@ -146,18 +163,15 @@ export class UsersService {
|
||||
dto: UpdateUserDto,
|
||||
): Promise<UserDocument> {
|
||||
await this.assertTargetIsAdmin(adminUserId);
|
||||
|
||||
const updated = await this.usersRepository.updateById(adminUserId, dto);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Admin not found');
|
||||
}
|
||||
const payload = await this.prepareManagedUserUpdatePayload(adminUserId, dto);
|
||||
const updated = await this.updateUserAndCleanupReplacedImages(adminUserId, payload);
|
||||
|
||||
await this.auditService.logSuperAdminAction(
|
||||
superAdminIdentifier,
|
||||
'admin_update',
|
||||
'user',
|
||||
adminUserId,
|
||||
{ fields: Object.keys(dto) },
|
||||
{ fields: Object.keys(payload) },
|
||||
);
|
||||
|
||||
return updated;
|
||||
@@ -165,7 +179,10 @@ export class UsersService {
|
||||
|
||||
async deleteAdminBySuperAdmin(superAdminIdentifier: string, adminUserId: string): Promise<void> {
|
||||
const admin = await this.assertTargetIsAdmin(adminUserId);
|
||||
await this.deleteUserRelatedData(adminUserId, admin.avatar ?? '');
|
||||
await this.deleteUserRelatedData(adminUserId, {
|
||||
avatarUrl: admin.avatar ?? '',
|
||||
coverImageUrl: admin.coverImage ?? '',
|
||||
});
|
||||
|
||||
await this.usersRepository.deleteById(adminUserId);
|
||||
|
||||
@@ -186,6 +203,7 @@ export class UsersService {
|
||||
throw new BadRequestException('Cannot assign superadmin role via API');
|
||||
}
|
||||
|
||||
await this.assertTargetIsManagedUser(targetUserId);
|
||||
const updated = await this.usersRepository.updateById(targetUserId, { role: dto.role });
|
||||
if (!updated) {
|
||||
throw new NotFoundException('User not found');
|
||||
@@ -210,6 +228,14 @@ export class UsersService {
|
||||
return user;
|
||||
}
|
||||
|
||||
async findPublicByIdOrFail(userId: string): Promise<UserDocument> {
|
||||
const user = await this.findByIdOrFail(userId);
|
||||
if (user.isDisabled) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async findByEmailWithPassword(email: string): Promise<UserDocument | null> {
|
||||
return this.usersRepository.findOneWithPassword({ email: email.toLowerCase() });
|
||||
}
|
||||
@@ -227,25 +253,38 @@ export class UsersService {
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
const currentUser = await this.findByIdOrFail(userId);
|
||||
const user = await this.updateUserAndCleanupReplacedImages(
|
||||
userId,
|
||||
{
|
||||
googleId,
|
||||
authProvider: 'google',
|
||||
...(avatar ? { avatar } : {}),
|
||||
},
|
||||
{ currentUser },
|
||||
);
|
||||
|
||||
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 updateProfile(
|
||||
userId: string,
|
||||
dto: UpdateUserDto,
|
||||
avatarFile?: UploadedImageFile,
|
||||
coverImageFile?: UploadedImageFile,
|
||||
): Promise<UserDocument> {
|
||||
const currentUser = await this.findByIdOrFail(userId);
|
||||
const payload: Record<string, unknown> = { ...dto };
|
||||
const uploadedImageUrls = await this.attachUploadedProfileImages(
|
||||
payload,
|
||||
avatarFile,
|
||||
coverImageFile,
|
||||
);
|
||||
|
||||
return this.updateUserAndCleanupReplacedImages(userId, payload, {
|
||||
currentUser,
|
||||
uploadedImageUrls,
|
||||
});
|
||||
}
|
||||
|
||||
async updatePassword(userId: string, passwordHash: string): Promise<void> {
|
||||
@@ -263,7 +302,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async findUserByIdForSuperAdmin(targetUserId: string): Promise<UserDocument> {
|
||||
return this.findByIdOrFail(targetUserId);
|
||||
return this.assertTargetIsManagedUser(targetUserId);
|
||||
}
|
||||
|
||||
async updateUserBySuperAdmin(
|
||||
@@ -271,24 +310,67 @@ export class UsersService {
|
||||
targetUserId: string,
|
||||
dto: UpdateUserDto,
|
||||
): Promise<UserDocument> {
|
||||
const user = await this.usersRepository.updateById(targetUserId, dto);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
await this.assertTargetIsManagedUser(targetUserId);
|
||||
const payload = await this.prepareManagedUserUpdatePayload(targetUserId, dto);
|
||||
const user = await this.updateUserAndCleanupReplacedImages(targetUserId, payload);
|
||||
await this.auditService.logSuperAdminAction(
|
||||
superAdminIdentifier,
|
||||
'user_update',
|
||||
'user',
|
||||
targetUserId,
|
||||
{ fields: Object.keys(dto) },
|
||||
{ fields: Object.keys(payload) },
|
||||
);
|
||||
return user;
|
||||
}
|
||||
|
||||
private async prepareManagedUserUpdatePayload(
|
||||
targetUserId: string,
|
||||
dto: UpdateUserDto,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const payload: Record<string, unknown> = { ...dto };
|
||||
|
||||
if (typeof dto.email === 'string') {
|
||||
const normalizedEmail = dto.email.trim().toLowerCase();
|
||||
if (!normalizedEmail) {
|
||||
throw new BadRequestException('Email is required');
|
||||
}
|
||||
|
||||
const existingUser = await this.usersRepository.findOne({
|
||||
email: normalizedEmail,
|
||||
_id: { $ne: targetUserId },
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
}
|
||||
|
||||
payload.email = normalizedEmail;
|
||||
}
|
||||
|
||||
if (typeof dto.username === 'string') {
|
||||
const normalizedUsername = dto.username.trim().toLowerCase();
|
||||
if (!normalizedUsername) {
|
||||
throw new BadRequestException('Username is required');
|
||||
}
|
||||
|
||||
const existingUser = await this.usersRepository.findOne({
|
||||
username: normalizedUsername,
|
||||
_id: { $ne: targetUserId },
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('Username already exists');
|
||||
}
|
||||
|
||||
payload.username = normalizedUsername;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async updateProfileSetup(
|
||||
userId: string,
|
||||
dto: ProfileSetupDto,
|
||||
avatarFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
avatarFile?: UploadedImageFile,
|
||||
coverImageFile?: UploadedImageFile,
|
||||
): Promise<UserDocument> {
|
||||
if (!Number.isFinite(dto.latitude) || !Number.isFinite(dto.longitude)) {
|
||||
throw new BadRequestException('latitude and longitude are required');
|
||||
@@ -296,66 +378,71 @@ export class UsersService {
|
||||
|
||||
const currentUser = await this.findByIdOrFail(userId);
|
||||
const payload: Record<string, unknown> = { ...dto };
|
||||
let uploadedAvatarUrl: string | null = null;
|
||||
const uploadedImageUrls = await this.attachUploadedProfileImages(
|
||||
payload,
|
||||
avatarFile,
|
||||
coverImageFile,
|
||||
);
|
||||
|
||||
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;
|
||||
return this.updateUserAndCleanupReplacedImages(userId, payload, {
|
||||
currentUser,
|
||||
uploadedImageUrls,
|
||||
});
|
||||
}
|
||||
|
||||
private async saveAvatarFile(
|
||||
avatarFile: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
private async saveAvatarFile(avatarFile: UploadedImageFile): Promise<string> {
|
||||
return this.saveProfileImageFile(avatarFile, {
|
||||
folderSegment: 'avatars',
|
||||
fileFieldName: 'avatarFile',
|
||||
fileNamePrefix: 'avatar',
|
||||
});
|
||||
}
|
||||
|
||||
private async saveCoverImageFile(coverImageFile: UploadedImageFile): Promise<string> {
|
||||
return this.saveProfileImageFile(coverImageFile, {
|
||||
folderSegment: 'profile-covers',
|
||||
fileFieldName: 'coverImageFile',
|
||||
fileNamePrefix: 'cover',
|
||||
});
|
||||
}
|
||||
|
||||
private async saveProfileImageFile(
|
||||
imageFile: UploadedImageFile,
|
||||
options: {
|
||||
folderSegment: string;
|
||||
fileFieldName: 'avatarFile' | 'coverImageFile';
|
||||
fileNamePrefix: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const extension = this.resolveAvatarExtension(avatarFile);
|
||||
const extension = this.resolveImageExtension(imageFile);
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
|
||||
if (!extension) {
|
||||
throw new BadRequestException('avatarFile must be png, jpg, jpeg, webp, or gif');
|
||||
throw new BadRequestException(`${options.fileFieldName} must be png, jpg, jpeg, webp, or gif`);
|
||||
}
|
||||
|
||||
if (avatarFile.size > maxSize) {
|
||||
throw new BadRequestException('avatarFile size must be 5MB or less');
|
||||
if (imageFile.size > maxSize) {
|
||||
throw new BadRequestException(`${options.fileFieldName} 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)}`;
|
||||
return this.storageService.saveFile({
|
||||
folderSegments: [options.folderSegment],
|
||||
extension,
|
||||
buffer: imageFile.buffer,
|
||||
contentType: imageFile.mimetype,
|
||||
fileNamePrefix: options.fileNamePrefix,
|
||||
});
|
||||
}
|
||||
|
||||
private resolveAvatarExtension(avatarFile: {
|
||||
mimetype?: string;
|
||||
originalname?: string;
|
||||
}): string | null {
|
||||
const originalExtension = extname(avatarFile.originalname ?? '').toLowerCase();
|
||||
private resolveImageExtension(imageFile: UploadedImageFile): string | null {
|
||||
const originalExtension = extname(imageFile.originalname ?? '').toLowerCase();
|
||||
const allowedExtensions = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
|
||||
|
||||
if (allowedExtensions.has(originalExtension)) {
|
||||
return originalExtension;
|
||||
}
|
||||
|
||||
switch (avatarFile.mimetype) {
|
||||
switch (imageFile.mimetype) {
|
||||
case 'image/png':
|
||||
return '.png';
|
||||
case 'image/jpeg':
|
||||
@@ -370,30 +457,69 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteManagedAvatar(avatarUrl?: string): Promise<void> {
|
||||
const marker = '/uploads/avatars/';
|
||||
const markerIndex = avatarUrl?.indexOf(marker) ?? -1;
|
||||
private async attachUploadedProfileImages(
|
||||
payload: Record<string, unknown>,
|
||||
avatarFile?: UploadedImageFile,
|
||||
coverImageFile?: UploadedImageFile,
|
||||
): Promise<string[]> {
|
||||
const uploadedImageUrls: string[] = [];
|
||||
|
||||
if (markerIndex === -1) {
|
||||
return;
|
||||
if (avatarFile) {
|
||||
const uploadedAvatarUrl = await this.saveAvatarFile(avatarFile);
|
||||
payload.avatar = uploadedAvatarUrl;
|
||||
uploadedImageUrls.push(uploadedAvatarUrl);
|
||||
}
|
||||
|
||||
const encodedFileName = avatarUrl!
|
||||
.slice(markerIndex + marker.length)
|
||||
.split('?')[0]
|
||||
.split('#')[0];
|
||||
|
||||
if (!encodedFileName) {
|
||||
return;
|
||||
if (coverImageFile) {
|
||||
const uploadedCoverImageUrl = await this.saveCoverImageFile(coverImageFile);
|
||||
payload.coverImage = uploadedCoverImageUrl;
|
||||
uploadedImageUrls.push(uploadedCoverImageUrl);
|
||||
}
|
||||
|
||||
const fileName = decodeURIComponent(encodedFileName);
|
||||
return uploadedImageUrls;
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(join(process.cwd(), 'uploads', 'avatars', fileName));
|
||||
} catch {
|
||||
// Ignore cleanup failures for already-missing files.
|
||||
private async updateUserAndCleanupReplacedImages(
|
||||
userId: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: {
|
||||
currentUser?: UserDocument;
|
||||
uploadedImageUrls?: string[];
|
||||
},
|
||||
): Promise<UserDocument> {
|
||||
const currentUser = options?.currentUser ?? (await this.findByIdOrFail(userId));
|
||||
const user = await this.usersRepository.updateById(userId, payload);
|
||||
|
||||
if (!user) {
|
||||
await Promise.all((options?.uploadedImageUrls ?? []).map((fileUrl) => this.deleteManagedUpload(fileUrl)));
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
await this.cleanupReplacedUserImages(currentUser, payload);
|
||||
return user;
|
||||
}
|
||||
|
||||
private async cleanupReplacedUserImages(
|
||||
currentUser: UserDocument,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const imageFields: Array<'avatar' | 'coverImage'> = ['avatar', 'coverImage'];
|
||||
|
||||
await Promise.all(
|
||||
imageFields.map(async (field) => {
|
||||
const nextValue = payload[field];
|
||||
if (typeof nextValue !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = (currentUser.get(field) as string | undefined) ?? '';
|
||||
if (nextValue === currentValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.deleteManagedUpload(currentValue);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async updateMusicSetup(userId: string, dto: MusicSetupDto): Promise<UserDocument> {
|
||||
@@ -411,7 +537,7 @@ export class UsersService {
|
||||
targetUserId: string,
|
||||
dto: AdminDisableUserDto,
|
||||
): Promise<UserDocument> {
|
||||
await this.findByIdOrFail(targetUserId);
|
||||
await this.assertTargetIsManagedUser(targetUserId);
|
||||
|
||||
const updated = await this.usersRepository.updateById(targetUserId, {
|
||||
isDisabled: true,
|
||||
@@ -433,6 +559,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async enableUserBySuperAdmin(superAdminIdentifier: string, targetUserId: string): Promise<UserDocument> {
|
||||
await this.assertTargetIsManagedUser(targetUserId);
|
||||
const updated = await this.usersRepository.updateById(targetUserId, {
|
||||
isDisabled: false,
|
||||
disabledAt: null,
|
||||
@@ -452,8 +579,11 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async deleteUserBySuperAdmin(superAdminIdentifier: string, targetUserId: string): Promise<void> {
|
||||
const user = await this.findByIdOrFail(targetUserId);
|
||||
await this.deleteUserRelatedData(targetUserId, user.avatar ?? '');
|
||||
const user = await this.assertTargetIsManagedUser(targetUserId);
|
||||
await this.deleteUserRelatedData(targetUserId, {
|
||||
avatarUrl: user.avatar ?? '',
|
||||
coverImageUrl: user.coverImage ?? '',
|
||||
});
|
||||
await this.usersRepository.deleteById(targetUserId);
|
||||
await this.auditService.logSuperAdminAction(
|
||||
superAdminIdentifier,
|
||||
@@ -474,30 +604,152 @@ export class UsersService {
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const filter: Record<string, unknown> = {};
|
||||
const filter = this.buildPublicUserFilter(query);
|
||||
|
||||
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 direction = resolveMongoSortDirection(query.sortOrder);
|
||||
const sortField = query.sortBy ?? 'createdAt';
|
||||
const sort = { [sortField]: direction } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.usersRepository.findMany(filter, skip, limit),
|
||||
this.usersRepository.findMany(filter, skip, limit, sort),
|
||||
this.usersRepository.count(filter),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
return buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
offset: skip,
|
||||
});
|
||||
}
|
||||
|
||||
async discoverTalents(query: TalentDiscoverQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 8;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const baseQuery: UserQueryDto = {
|
||||
...query,
|
||||
hasAvatar: query.hasAvatarOnly ?? true,
|
||||
};
|
||||
const filter = this.buildPublicUserFilter(baseQuery, {
|
||||
forcePublicTalentsOnly: true,
|
||||
});
|
||||
const direction = resolveMongoSortDirection(query.sortOrder);
|
||||
const sortField = query.sortBy ?? 'followersCount';
|
||||
const sort = { [sortField]: direction } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total, roleBuckets] = await Promise.all([
|
||||
this.usersRepository.findMany(filter, skip, limit, sort),
|
||||
this.usersRepository.count(filter),
|
||||
query.includeRoleBuckets === false
|
||||
? Promise.resolve([])
|
||||
: this.buildTalentRoleBuckets(baseQuery),
|
||||
]);
|
||||
|
||||
const result = buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
offset: skip,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
roleBuckets,
|
||||
activeRole: query.musicRole ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async discoverTalentsForSuperAdmin(query: TalentDiscoverQueryDto) {
|
||||
return this.discoverTalents(query);
|
||||
}
|
||||
|
||||
async getProfileOverview(userId: string, viewerUserId: string) {
|
||||
const user = await this.findPublicByIdOrFail(userId);
|
||||
const objectUserId = new Types.ObjectId(userId);
|
||||
const postsCollection = this.connection.collection('posts');
|
||||
const followsCollection = this.connection.collection('follows');
|
||||
|
||||
const [reelsCount, audioCount, imageCount, textCount, collaborationsCount, followingState] =
|
||||
await Promise.all([
|
||||
postsCollection.countDocuments({
|
||||
authorId: objectUserId,
|
||||
postType: 'video',
|
||||
isDeleted: false,
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
}),
|
||||
postsCollection.countDocuments({
|
||||
authorId: objectUserId,
|
||||
postType: 'audio',
|
||||
isDeleted: false,
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
}),
|
||||
postsCollection.countDocuments({
|
||||
authorId: objectUserId,
|
||||
postType: 'image',
|
||||
isDeleted: false,
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
}),
|
||||
postsCollection.countDocuments({
|
||||
authorId: objectUserId,
|
||||
postType: 'text',
|
||||
isDeleted: false,
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
}),
|
||||
postsCollection.countDocuments({
|
||||
isDeleted: false,
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
$or: [
|
||||
{ authorId: objectUserId, 'taggedUserIds.0': { $exists: true } },
|
||||
{ authorId: { $ne: objectUserId }, taggedUserIds: objectUserId },
|
||||
],
|
||||
}),
|
||||
viewerUserId === userId
|
||||
? Promise.resolve(false)
|
||||
: followsCollection.countDocuments({
|
||||
followerId: new Types.ObjectId(viewerUserId),
|
||||
followingId: objectUserId,
|
||||
}).then((count) => count > 0),
|
||||
]);
|
||||
|
||||
return {
|
||||
user,
|
||||
stats: {
|
||||
followersCount: user.followersCount ?? 0,
|
||||
followingCount: user.followingCount ?? 0,
|
||||
postsCount: user.postsCount ?? 0,
|
||||
collaborationsCount,
|
||||
},
|
||||
contentCounts: {
|
||||
reels: reelsCount,
|
||||
audio: audioCount,
|
||||
image: imageCount,
|
||||
text: textCount,
|
||||
other: imageCount + textCount,
|
||||
},
|
||||
tabs: [
|
||||
{ key: 'reels', postType: 'video', count: reelsCount },
|
||||
{ key: 'audio', postType: 'audio', count: audioCount },
|
||||
{ key: 'other', count: imageCount + textCount },
|
||||
],
|
||||
viewerState: {
|
||||
isOwnProfile: viewerUserId === userId,
|
||||
following: followingState,
|
||||
canMessage: viewerUserId !== userId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getProfileOverviewForSuperAdmin(userId: string) {
|
||||
const overview = await this.getProfileOverview(userId, userId);
|
||||
return {
|
||||
...overview,
|
||||
viewerState: {
|
||||
isOwnProfile: false,
|
||||
following: false,
|
||||
canMessage: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -508,7 +760,26 @@ export class UsersService {
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
return this.searchUsers(query);
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const filter = this.buildAdminUserSearchFilter(query);
|
||||
const direction = resolveMongoSortDirection(query.sortOrder);
|
||||
const sortField = query.sortBy ?? 'createdAt';
|
||||
const sort = { [sortField]: direction } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.usersRepository.findMany(filter, skip, limit, sort),
|
||||
this.usersRepository.count(filter),
|
||||
]);
|
||||
|
||||
return buildPaginatedResponse(items, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
offset: skip,
|
||||
});
|
||||
}
|
||||
|
||||
private async assertTargetIsAdmin(userId: string): Promise<UserDocument> {
|
||||
@@ -519,7 +790,21 @@ export class UsersService {
|
||||
return user;
|
||||
}
|
||||
|
||||
private async deleteUserRelatedData(userId: string, avatarUrl?: string): Promise<void> {
|
||||
private async assertTargetIsManagedUser(userId: string): Promise<UserDocument> {
|
||||
const user = await this.findByIdOrFail(userId);
|
||||
if (user.role === UserRole.SUPERADMIN) {
|
||||
throw new ForbiddenException('Superadmin accounts cannot be managed through this endpoint');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private async deleteUserRelatedData(
|
||||
userId: string,
|
||||
options?: {
|
||||
avatarUrl?: string;
|
||||
coverImageUrl?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!Types.ObjectId.isValid(userId)) {
|
||||
return;
|
||||
}
|
||||
@@ -566,8 +851,11 @@ export class UsersService {
|
||||
: [];
|
||||
|
||||
const localFilesToDelete = new Set<string>();
|
||||
if (avatarUrl) {
|
||||
localFilesToDelete.add(avatarUrl);
|
||||
if (options?.avatarUrl) {
|
||||
localFilesToDelete.add(options.avatarUrl);
|
||||
}
|
||||
if (options?.coverImageUrl) {
|
||||
localFilesToDelete.add(options.coverImageUrl);
|
||||
}
|
||||
|
||||
for (const post of adminPosts) {
|
||||
@@ -629,21 +917,130 @@ export class UsersService {
|
||||
}
|
||||
|
||||
private async deleteManagedUpload(fileUrl: string): Promise<void> {
|
||||
if (!fileUrl?.startsWith('/uploads/')) {
|
||||
return;
|
||||
await this.storageService.deleteFile(fileUrl);
|
||||
}
|
||||
|
||||
private buildPublicUserFilter(
|
||||
query: Pick<UserQueryDto, 'q' | 'isVerified' | 'musicRole' | 'experienceLevel' | 'isPrivate' | 'hasAvatar'>,
|
||||
options?: { forcePublicTalentsOnly?: boolean },
|
||||
): Record<string, unknown> {
|
||||
const clauses: Record<string, unknown>[] = [{ isDisabled: false }];
|
||||
|
||||
if (query.q?.trim()) {
|
||||
clauses.push({
|
||||
$or: [
|
||||
{ name: { $regex: query.q.trim(), $options: 'i' } },
|
||||
{ username: { $regex: query.q.trim(), $options: 'i' } },
|
||||
{ stageName: { $regex: query.q.trim(), $options: 'i' } },
|
||||
{ bio: { $regex: query.q.trim(), $options: 'i' } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const relativePath = fileUrl.split('?')[0].split('#')[0].replace(/^\/+/, '');
|
||||
const normalizedPath = relativePath.replace(/\//g, '\\');
|
||||
|
||||
if (normalizedPath.includes('..')) {
|
||||
return;
|
||||
if (typeof query.isVerified === 'boolean') {
|
||||
clauses.push({ isVerified: query.isVerified });
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(join(process.cwd(), normalizedPath));
|
||||
} catch {
|
||||
// Ignore cleanup failures for already-missing files.
|
||||
if (query.musicRole) {
|
||||
clauses.push({ musicRoles: query.musicRole });
|
||||
}
|
||||
|
||||
if (query.experienceLevel) {
|
||||
clauses.push({ experienceLevel: query.experienceLevel });
|
||||
}
|
||||
|
||||
if (typeof query.isPrivate === 'boolean') {
|
||||
clauses.push({ isPrivate: query.isPrivate });
|
||||
}
|
||||
|
||||
if (typeof query.hasAvatar === 'boolean') {
|
||||
clauses.push(
|
||||
query.hasAvatar
|
||||
? { avatar: { $ne: '' } }
|
||||
: {
|
||||
$or: [{ avatar: '' }, { avatar: { $exists: false } }],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.forcePublicTalentsOnly) {
|
||||
clauses.push({ role: UserRole.USER });
|
||||
clauses.push({ isPrivate: false });
|
||||
clauses.push({ musicRoles: { $exists: true, $ne: [] } });
|
||||
}
|
||||
|
||||
if (clauses.length === 1) {
|
||||
return clauses[0];
|
||||
}
|
||||
|
||||
return { $and: clauses };
|
||||
}
|
||||
|
||||
private async buildTalentRoleBuckets(
|
||||
query: Pick<UserQueryDto, 'q' | 'isVerified' | 'experienceLevel' | 'isPrivate' | 'hasAvatar'>,
|
||||
) {
|
||||
const roles = Object.values(MusicRole);
|
||||
return Promise.all(
|
||||
roles.map(async (role) => ({
|
||||
role,
|
||||
count: await this.usersRepository.count(
|
||||
this.buildPublicUserFilter(
|
||||
{
|
||||
...query,
|
||||
musicRole: role,
|
||||
},
|
||||
{ forcePublicTalentsOnly: true },
|
||||
),
|
||||
),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private buildAdminUserSearchFilter(query: UserQueryDto): Record<string, unknown> {
|
||||
const clauses: Record<string, unknown>[] = [{ role: { $ne: UserRole.SUPERADMIN } }];
|
||||
|
||||
if (query.q?.trim()) {
|
||||
clauses.push({
|
||||
$or: [
|
||||
{ name: { $regex: query.q.trim(), $options: 'i' } },
|
||||
{ username: { $regex: query.q.trim(), $options: 'i' } },
|
||||
{ email: { $regex: query.q.trim(), $options: 'i' } },
|
||||
{ stageName: { $regex: query.q.trim(), $options: 'i' } },
|
||||
{ bio: { $regex: query.q.trim(), $options: 'i' } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof query.isVerified === 'boolean') {
|
||||
clauses.push({ isVerified: query.isVerified });
|
||||
}
|
||||
|
||||
if (query.musicRole) {
|
||||
clauses.push({ musicRoles: query.musicRole });
|
||||
}
|
||||
|
||||
if (query.experienceLevel) {
|
||||
clauses.push({ experienceLevel: query.experienceLevel });
|
||||
}
|
||||
|
||||
if (typeof query.isPrivate === 'boolean') {
|
||||
clauses.push({ isPrivate: query.isPrivate });
|
||||
}
|
||||
|
||||
if (typeof query.hasAvatar === 'boolean') {
|
||||
clauses.push(
|
||||
query.hasAvatar
|
||||
? { avatar: { $ne: '' } }
|
||||
: {
|
||||
$or: [{ avatar: '' }, { avatar: { $exists: false } }],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (clauses.length === 1) {
|
||||
return clauses[0];
|
||||
}
|
||||
|
||||
return { $and: clauses };
|
||||
}
|
||||
}
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم