feat: expand backend admin marketplace and scaling
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s

هذا الالتزام موجود في:
2026-05-14 16:17:12 +03:00
الأصل 0e76a4a9fc
التزام 5bd5e19a89
158 ملفات معدلة مع 19563 إضافات و3315 حذوفات

عرض الملف

@@ -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)

عرض الملف

@@ -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 };
}
}