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 حذوفات

عرض الملف

@@ -0,0 +1,22 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
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 { AuditService } from './audit.service';
import { AuditQueryDto } from './dto/audit-query.dto';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@ApiTags('Audit')
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@Controller('audit/superadmin')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get('logs')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.AUDIT_READ)
async listLogs(@Query() query: AuditQueryDto) {
return this.auditService.listSuperAdminLogs(query);
}
}

عرض الملف

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AuditController } from './audit.controller';
import { AuditRepository } from './audit.repository';
import { AuditService } from './audit.service';
import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
@@ -13,6 +14,7 @@ import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
},
]),
],
controllers: [AuditController],
providers: [AuditRepository, AuditService],
exports: [AuditService],
})

عرض الملف

@@ -26,4 +26,17 @@ export class AuditRepository {
metadata: payload.metadata ?? {},
});
}
async findMany(
filter: Record<string, unknown>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<AuditLogDocument[]> {
return this.auditModel.find(filter).sort(sort).skip(skip).limit(limit).exec();
}
async count(filter: Record<string, unknown>): Promise<number> {
return this.auditModel.countDocuments(filter).exec();
}
}

عرض الملف

@@ -1,5 +1,8 @@
import { Injectable } from '@nestjs/common';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { AuditRepository } from './audit.repository';
import { AuditQueryDto } from './dto/audit-query.dto';
@Injectable()
export class AuditService {
@@ -21,4 +24,41 @@ export class AuditService {
metadata,
});
}
async listSuperAdminLogs(query: AuditQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const filter: Record<string, unknown> = {};
if (query.q?.trim()) {
filter.$or = [
{ action: { $regex: query.q.trim(), $options: 'i' } },
{ targetType: { $regex: query.q.trim(), $options: 'i' } },
{ targetId: { $regex: query.q.trim(), $options: 'i' } },
{ actorIdentifier: { $regex: query.q.trim(), $options: 'i' } },
];
}
if (query.actorType) {
filter.actorType = query.actorType;
}
if (query.targetType?.trim()) {
filter.targetType = query.targetType.trim();
}
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.auditRepository.findMany(filter, skip, limit, sort),
this.auditRepository.count(filter),
]);
return buildPaginatedResponse(items, {
page,
limit,
total,
offset: skip,
});
}
}

عرض الملف

@@ -0,0 +1,22 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
const ACTOR_TYPES = ['user', 'superadmin', 'system'] as const;
export class AuditQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Search in action, targetType, targetId, or actorIdentifier' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ enum: ACTOR_TYPES })
@IsOptional()
@IsEnum(ACTOR_TYPES)
actorType?: (typeof ACTOR_TYPES)[number];
@ApiPropertyOptional()
@IsOptional()
@IsString()
targetType?: string;
}

عرض الملف

@@ -3,7 +3,10 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { Throttle } from '../../common/decorators/throttle.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
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 { AuthService } from './auth.service';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
@@ -18,6 +21,7 @@ import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
import { VerifyEmailDto } from './dto/verify-email.dto';
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@ApiTags('Auth')
@Controller('auth')
@@ -151,4 +155,24 @@ export class AuthController {
await this.authService.revokeUserSession(user.sub, jti);
return { success: true };
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE)
@Get('superadmin/sessions')
async listSuperAdminSessions(@CurrentUser() user: JwtPayload) {
return this.authService.listSuperAdminSessions(user.email ?? '');
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE)
@Post('superadmin/sessions/:sessionId/revoke')
async revokeSuperAdminSession(
@CurrentUser() user: JwtPayload,
@Param('sessionId') sessionId: string,
) {
await this.authService.revokeSuperAdminSession(user.email ?? '', sessionId);
return { success: true };
}
}

عرض الملف

@@ -91,11 +91,13 @@ export class AuthRepository {
async createSuperAdminRefreshToken(
adminEmail: string,
jti: string,
tokenHash: string,
expiresAt: Date,
): Promise<void> {
await this.superAdminRefreshTokenModel.create({
adminEmail: adminEmail.toLowerCase(),
jti,
tokenHash,
expiresAt,
});
@@ -108,12 +110,28 @@ export class AuthRepository {
.exec();
}
async findActiveSuperAdminTokenByJti(
adminEmail: string,
jti: string,
): Promise<SuperAdminRefreshTokenDocument | null> {
return this.superAdminRefreshTokenModel
.findOne({ adminEmail: adminEmail.toLowerCase(), jti, revoked: false })
.select('+tokenHash')
.exec();
}
async revokeAllSuperAdminTokens(adminEmail: string): Promise<void> {
await this.superAdminRefreshTokenModel
.updateMany({ adminEmail: adminEmail.toLowerCase(), revoked: false }, { revoked: true })
.exec();
}
async revokeSuperAdminTokenByJti(adminEmail: string, jti: string): Promise<void> {
await this.superAdminRefreshTokenModel
.updateOne({ adminEmail: adminEmail.toLowerCase(), jti, revoked: false }, { revoked: true })
.exec();
}
async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise<void> {
await this.superAdminRefreshTokenModel
.deleteMany({
@@ -123,6 +141,34 @@ export class AuthRepository {
.exec();
}
async listSuperAdminSessions(adminEmail: string): Promise<SuperAdminRefreshTokenDocument[]> {
return this.superAdminRefreshTokenModel
.find({
adminEmail: adminEmail.toLowerCase(),
revoked: false,
expiresAt: { $gt: new Date() },
})
.select('adminEmail jti expiresAt createdAt')
.sort({ createdAt: -1 })
.exec();
}
async revokeSuperAdminSessionById(adminEmail: string, sessionId: string): Promise<boolean> {
const updated = await this.superAdminRefreshTokenModel
.findOneAndUpdate(
{
_id: new Types.ObjectId(sessionId),
adminEmail: adminEmail.toLowerCase(),
revoked: false,
},
{ revoked: true },
{ new: false },
)
.exec();
return !!updated;
}
async invalidateActivePasswordResetCodes(userId: string): Promise<void> {
await this.passwordResetCodeModel
.updateMany(

عرض الملف

@@ -9,7 +9,12 @@ import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { randomBytes, randomInt, randomUUID } from 'crypto';
import { OAuth2Client } from 'google-auth-library';
import { compareHash, hashValue } from '../../common/utils/hash.util';
import {
compareHash,
compareStoredHighEntropyValue,
hashHighEntropyValue,
hashValue,
} from '../../common/utils/hash.util';
import { EmailService } from '../email/email.service';
import { UsersService } from '../users/users.service';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
@@ -25,6 +30,7 @@ import { VerifyEmailDto } from './dto/verify-email.dto';
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
import { AuthRepository } from './auth.repository';
import { AuthResult, TokenPair } from './types/token-pair.type';
import { DEFAULT_SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@Injectable()
export class AuthService {
@@ -56,16 +62,10 @@ export class AuthService {
username: generatedUsername,
password: passwordHash,
});
const code = await this.issueEmailVerificationCode(user.id, user.email);
const response: { message: string; email: string; debugCode?: string } = {
message: 'Registration successful. Verify your email with the code sent.',
return {
message: 'Registration successful. Account is pending SuperAdmin verification.',
email: user.email,
};
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
if (nodeEnv !== 'production') {
response.debugCode = code;
}
return response;
}
async registerBasic(dto: RegisterBasicDto): Promise<{ message: string; email: string; debugCode?: string }> {
@@ -84,16 +84,10 @@ export class AuthService {
password: passwordHash,
});
const code = await this.issueEmailVerificationCode(user.id, user.email);
const response: { message: string; email: string; debugCode?: string } = {
message: 'Registration successful. Verify your email with the code sent.',
return {
message: 'Registration successful. Account is pending SuperAdmin verification.',
email: user.email,
};
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
if (nodeEnv !== 'production') {
response.debugCode = code;
}
return response;
}
async login(dto: LoginDto): Promise<AuthResult> {
@@ -105,7 +99,7 @@ export class AuthService {
throw new ForbiddenException('Account is disabled');
}
if (!user.isVerified) {
throw new ForbiddenException('Email not verified');
throw new ForbiddenException('Account is pending SuperAdmin verification');
}
const isMatch = await compareHash(dto.password, user.password);
@@ -123,71 +117,20 @@ export class AuthService {
): Promise<{ message: string; debugCode?: string }> {
const normalizedEmail = dto.email.toLowerCase();
const user = await this.usersService.findByEmail(normalizedEmail);
const message = 'If this email exists, a verification code was sent';
const message = 'Account verification is managed by SuperAdmin';
if (!user || user.isDisabled) {
return { message };
}
if (user.isVerified) {
return { message: 'Email is already verified' };
return { message: 'Account is already verified' };
}
const code = await this.issueEmailVerificationCode(user.id, user.email);
const response: { message: string; debugCode?: string } = { message };
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
if (nodeEnv !== 'production') {
response.debugCode = code;
}
return response;
return { message: 'Account is pending SuperAdmin verification' };
}
async verifyEmail(dto: VerifyEmailDto): Promise<AuthResult & { message: string }> {
const normalizedEmail = dto.email.toLowerCase();
const user = await this.usersService.findByEmail(normalizedEmail);
if (!user || user.isDisabled) {
throw new UnauthorizedException('Invalid or expired verification code');
}
if (user.isVerified) {
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
const safeUser = await this.usersService.findByIdOrFail(user.id);
return {
message: 'Email already verified',
...tokens,
user: safeUser.toObject() as unknown as Record<string, unknown>,
};
}
const codeRecord = await this.authRepository.findLatestActiveEmailVerificationCode(user.id);
if (!codeRecord) {
throw new UnauthorizedException('Invalid or expired verification code');
}
const maxAttempts = this.configService.get<number>('emailVerification.maxAttempts', { infer: true });
if (codeRecord.attempts >= maxAttempts) {
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
throw new UnauthorizedException('Verification code attempts exceeded');
}
const isMatch = await compareHash(dto.code, codeRecord.codeHash);
if (!isMatch) {
await this.authRepository.incrementEmailVerificationAttempts(codeRecord.id);
if (codeRecord.attempts + 1 >= maxAttempts) {
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
}
throw new UnauthorizedException('Invalid or expired verification code');
}
await this.usersService.markEmailVerified(user.id);
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
await this.authRepository.markAllEmailVerificationCodesUsedByUser(user.id);
const safeUser = await this.usersService.findByIdOrFail(user.id);
const tokens = await this.generateAndStoreTokenPair(safeUser.id, safeUser.username, safeUser.role ?? 'user');
async verifyEmail(_dto: VerifyEmailDto): Promise<{ message: string }> {
return {
message: 'Email verified successfully',
...tokens,
user: safeUser.toObject() as unknown as Record<string, unknown>,
message: 'Account verification is managed by SuperAdmin',
};
}
@@ -209,7 +152,11 @@ export class AuthService {
throw new UnauthorizedException('Refresh token reuse detected');
}
const isMatch = await compareHash(dto.refreshToken, tokenRecord.tokenHash);
const isMatch = await compareStoredHighEntropyValue(
dto.refreshToken,
tokenRecord.tokenHash,
this.getRefreshTokenHashSecret(),
);
if (!isMatch) {
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
throw new UnauthorizedException('Refresh token reuse detected');
@@ -265,7 +212,7 @@ export class AuthService {
email: googleUser.email,
password: passwordHash,
avatar: googleUser.avatar ?? '',
isVerified: true,
isVerified: false,
});
}
@@ -277,6 +224,10 @@ export class AuthService {
throw new ForbiddenException('Account is disabled');
}
if (!user.isVerified) {
throw new ForbiddenException('Account is pending SuperAdmin verification');
}
const tokens = await this.generateAndStoreTokenPair(user.id, user.username, user.role ?? 'user');
const safeUser = await this.usersService.findByIdOrFail(user.id);
return { ...tokens, user: safeUser.toObject() as unknown as Record<string, unknown> };
@@ -345,43 +296,51 @@ export class AuthService {
refreshToken: string;
superAdmin: { email: string };
}> {
const decoded = this.jwtService.verify<{ email: string; tokenType: string }>(dto.refreshToken, {
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
});
const decoded = this.jwtService.verify<{ email: string; tokenType: string; jti?: string }>(
dto.refreshToken,
{
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
},
);
if (decoded.tokenType !== 'superadmin_refresh' || !decoded.email) {
if (decoded.tokenType !== 'superadmin_refresh' || !decoded.email || !decoded.jti) {
throw new UnauthorizedException('Invalid superadmin refresh token');
}
const activeTokens = await this.authRepository.findActiveSuperAdminTokens(decoded.email);
if (!activeTokens.length) {
throw new UnauthorizedException('Invalid superadmin refresh token');
const tokenRecord = await this.authRepository.findActiveSuperAdminTokenByJti(
decoded.email,
decoded.jti,
);
if (!tokenRecord) {
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
throw new UnauthorizedException('Superadmin refresh token reuse detected');
}
let validTokenFound = false;
for (const token of activeTokens) {
const isMatch = await compareHash(dto.refreshToken, token.tokenHash);
if (isMatch) {
validTokenFound = true;
break;
}
const isMatch = await compareStoredHighEntropyValue(
dto.refreshToken,
tokenRecord.tokenHash,
this.getRefreshTokenHashSecret(),
);
if (!isMatch) {
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
throw new UnauthorizedException('Superadmin refresh token reuse detected');
}
if (!validTokenFound) {
throw new UnauthorizedException('Invalid superadmin refresh token');
}
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
await this.authRepository.revokeSuperAdminTokenByJti(decoded.email, decoded.jti);
const tokens = await this.generateAndStoreSuperAdminTokenPair(decoded.email);
return { ...tokens, superAdmin: { email: decoded.email } };
}
async superAdminLogout(dto: RefreshTokenDto): Promise<void> {
try {
const decoded = this.jwtService.verify<{ email: string }>(dto.refreshToken, {
const decoded = this.jwtService.verify<{ email: string; jti?: string }>(dto.refreshToken, {
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
});
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
if (decoded.jti) {
await this.authRepository.revokeSuperAdminTokenByJti(decoded.email, decoded.jti);
} else {
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
}
await this.authRepository.removeExpiredAndRevokedSuperAdmin(decoded.email);
} catch {
throw new BadRequestException('Invalid superadmin refresh token');
@@ -403,6 +362,27 @@ export class AuthService {
await this.authRepository.revokeUserTokenByJti(userId, jti);
}
async listSuperAdminSessions(
adminEmail: string,
): Promise<{ items: Array<{ id: string; jti: string; createdAt: Date; expiresAt: Date }> }> {
const sessions = await this.authRepository.listSuperAdminSessions(adminEmail);
return {
items: sessions.map((session) => ({
id: session.id,
jti: session.jti,
createdAt: (session as unknown as { createdAt: Date }).createdAt,
expiresAt: session.expiresAt,
})),
};
}
async revokeSuperAdminSession(adminEmail: string, sessionId: string): Promise<void> {
const revoked = await this.authRepository.revokeSuperAdminSessionById(adminEmail, sessionId);
if (!revoked) {
throw new BadRequestException('Superadmin session not found');
}
}
async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string; debugCode?: string }> {
const normalizedEmail = dto.email.toLowerCase();
const user = await this.usersService.findByEmail(normalizedEmail);
@@ -549,8 +529,7 @@ export class AuthService {
),
]);
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
const tokenHash = await hashValue(refreshToken, saltRounds);
const tokenHash = hashHighEntropyValue(refreshToken, this.getRefreshTokenHashSecret());
const refreshExpiresIn = this.configService.get<string>('jwt.refreshExpiresIn', {
infer: true,
@@ -563,6 +542,8 @@ export class AuthService {
}
private async generateAndStoreSuperAdminTokenPair(adminEmail: string): Promise<TokenPair> {
const permissions = this.getSuperAdminPermissions();
const refreshJti = randomUUID();
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{
@@ -571,6 +552,7 @@ export class AuthService {
email: adminEmail.toLowerCase(),
role: 'superadmin',
tokenType: 'superadmin_access',
permissions,
},
{
secret: this.configService.get<string>('superAdmin.accessSecret', { infer: true }),
@@ -584,6 +566,7 @@ export class AuthService {
email: adminEmail.toLowerCase(),
role: 'superadmin',
tokenType: 'superadmin_refresh',
jti: refreshJti,
},
{
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
@@ -592,8 +575,7 @@ export class AuthService {
),
]);
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
const tokenHash = await hashValue(refreshToken, saltRounds);
const tokenHash = hashHighEntropyValue(refreshToken, this.getRefreshTokenHashSecret());
const refreshExpiresIn = this.configService.get<string>('superAdmin.refreshExpiresIn', {
infer: true,
});
@@ -601,6 +583,7 @@ export class AuthService {
await this.authRepository.createSuperAdminRefreshToken(
adminEmail,
refreshJti,
tokenHash,
new Date(Date.now() + refreshExpiresInMs),
);
@@ -647,6 +630,18 @@ export class AuthService {
return String(randomInt(100000, 1000000));
}
private getRefreshTokenHashSecret(): string {
return (
this.configService.get<string>('security.refreshTokenHashSecret', { infer: true }) ??
this.configService.get<string>('jwt.refreshSecret', { infer: true }) ??
''
);
}
private getSuperAdminPermissions(): string[] {
return [...DEFAULT_SUPERADMIN_PERMISSIONS];
}
private async issueEmailVerificationCode(userId: string, email: string): Promise<string> {
const code = this.generateResetCode();
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });

عرض الملف

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
@@ -15,6 +15,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 RegisterDto {
@ApiProperty({ example: 'john@example.com' })
@@ -78,6 +79,7 @@ export class RegisterDto {
@ApiProperty({ required: false, default: false })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isPrivate?: boolean;

عرض الملف

@@ -12,7 +12,7 @@ export class EmailVerificationCode {
@Prop({ required: true, select: false })
codeHash!: string;
@Prop({ required: true, index: true })
@Prop({ required: true })
expiresAt!: Date;
@Prop({ default: 0, min: 0 })

عرض الملف

@@ -12,7 +12,7 @@ export class PasswordResetCode {
@Prop({ required: true, select: false })
codeHash!: string;
@Prop({ required: true, index: true })
@Prop({ required: true })
expiresAt!: Date;
@Prop({ default: 0, min: 0 })

عرض الملف

@@ -8,6 +8,9 @@ export class SuperAdminRefreshToken {
@Prop({ required: true, trim: true, lowercase: true, index: true })
adminEmail!: string;
@Prop({ required: true, trim: true, index: true })
jti!: string;
@Prop({ required: true, select: false })
tokenHash!: string;
@@ -20,4 +23,5 @@ export class SuperAdminRefreshToken {
export const SuperAdminRefreshTokenSchema = SchemaFactory.createForClass(SuperAdminRefreshToken);
SuperAdminRefreshTokenSchema.index({ adminEmail: 1, revoked: 1 });
SuperAdminRefreshTokenSchema.index({ adminEmail: 1, jti: 1 }, { unique: true, sparse: true });
SuperAdminRefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

عرض الملف

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { MongooseModule } from '@nestjs/mongoose';
import { NotificationsModule } from '../notifications/notifications.module';
import { UsersModule } from '../users/users.module';
import { ChatController } from './chat.controller';
import { ChatGateway } from './chat.gateway';
@@ -15,6 +16,7 @@ import { Message, MessageSchema } from './schemas/message.schema';
imports: [
ConfigModule,
JwtModule.register({}),
NotificationsModule,
UsersModule,
MongooseModule.forFeature([
{ name: Conversation.name, schema: ConversationSchema },

عرض الملف

@@ -51,11 +51,16 @@ export class ChatRepository {
});
}
async findConversationsForUser(userId: string, skip: number, limit: number): Promise<ConversationDocument[]> {
async findConversationsForUser(
userId: string,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { lastMessageAt: -1, updatedAt: -1 },
): Promise<ConversationDocument[]> {
return this.conversationModel
.find({ participantIds: new Types.ObjectId(userId) })
.populate({ path: 'participantIds', select: 'name username stageName avatar isVerified isDisabled' })
.sort({ lastMessageAt: -1, updatedAt: -1 })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
@@ -83,11 +88,16 @@ export class ChatRepository {
});
}
async findMessages(conversationId: string, skip: number, limit: number): Promise<MessageDocument[]> {
async findMessages(
conversationId: string,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<MessageDocument[]> {
return this.messageModel
.find({ conversationId: new Types.ObjectId(conversationId) })
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
.sort({ createdAt: -1 })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();

عرض الملف

@@ -1,6 +1,9 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { NotificationsService } from '../notifications/notifications.service';
import { UsersRepository } from '../users/users.repository';
import { CreateConversationDto } from './dto/create-conversation.dto';
import { MessageQueryDto } from './dto/message-query.dto';
@@ -9,9 +12,12 @@ import { ChatRepository } from './chat.repository';
@Injectable()
export class ChatService {
private readonly logger = new Logger(ChatService.name);
constructor(
private readonly chatRepository: ChatRepository,
private readonly usersRepository: UsersRepository,
private readonly notificationsService: NotificationsService,
) {}
async createConversation(currentUserId: string, dto: CreateConversationDto) {
@@ -61,9 +67,13 @@ export class ChatService {
const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor);
const skip = cursorOffset ?? (page - 1) * limit;
const direction = resolveMongoSortDirection(query.sortOrder);
const [items, total] = await Promise.all([
this.chatRepository.findConversationsForUser(currentUserId, skip, limit),
this.chatRepository.findConversationsForUser(currentUserId, skip, limit, {
lastMessageAt: direction,
updatedAt: direction,
}),
this.chatRepository.countConversationsForUser(currentUserId),
]);
@@ -78,14 +88,15 @@ export class ChatService {
const nextOffset = skip + mappedItems.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
return {
items: mappedItems,
return buildPaginatedResponse(mappedItems, {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
offset: skip,
currentCursor: query.cursor ?? null,
nextCursor,
};
mode: 'cursor',
});
}
async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) {
@@ -94,9 +105,10 @@ export class ChatService {
const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor);
const skip = cursorOffset ?? (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.chatRepository.findMessages(conversation.id, skip, limit),
this.chatRepository.findMessages(conversation.id, skip, limit, sort),
this.chatRepository.countMessages(conversation.id),
]);
@@ -104,14 +116,15 @@ export class ChatService {
const nextOffset = skip + items.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
return {
items,
return buildPaginatedResponse(items, {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
offset: skip,
currentCursor: query.cursor ?? null,
nextCursor,
};
mode: 'cursor',
});
}
async sendMessage(currentUserId: string, dto: SendMessageDto) {
@@ -144,6 +157,12 @@ export class ChatService {
currentUserId,
preview,
);
await this.dispatchMessageNotifications(
currentUserId,
conversation.participantIds.map((id) => id.toString()),
conversation.id,
preview,
);
return message;
}
@@ -247,4 +266,32 @@ export class ChatService {
}
}
}
private async dispatchMessageNotifications(
actorId: string,
participantIds: string[],
conversationId: string,
previewText: string,
): Promise<void> {
for (const recipientId of participantIds) {
if (recipientId === actorId) {
continue;
}
try {
await this.notificationsService.createMessageNotification(
actorId,
recipientId,
conversationId,
previewText.slice(0, 160),
);
} catch (error) {
this.logger.warn(
`Message notification failed for actor=${actorId} recipient=${recipientId}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
}
}
}
}

عرض الملف

@@ -1,5 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
import { toBoolean } from '../../../common/utils/query-transform.util';
export class CreateConversationDto {
@IsArray()
@@ -8,6 +10,7 @@ export class CreateConversationDto {
@ApiPropertyOptional({ default: false })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isGroup?: boolean;

عرض الملف

@@ -1,5 +1,6 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { resolveManagedFileUrl } from '../../../common/utils/public-url.util';
import { User } from '../../users/schemas/user.schema';
export type MessageDocument = HydratedDocument<Message>;
@@ -31,3 +32,11 @@ export class Message {
export const MessageSchema = SchemaFactory.createForClass(Message);
MessageSchema.index({ conversationId: 1, createdAt: -1 });
MessageSchema.index({ conversationId: 1, isUnsent: 1, createdAt: -1 });
const transformManagedMessageFiles = (_doc: unknown, ret: any) => {
ret.mediaUrl = resolveManagedFileUrl(ret.mediaUrl);
return ret;
};
MessageSchema.set('toJSON', { transform: transformManagedMessageFiles });
MessageSchema.set('toObject', { transform: transformManagedMessageFiles });

عرض الملف

@@ -1,12 +1,16 @@
import { Controller, Delete, Get, Param, Post, Query, Body, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
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 { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
import { CommentQueryDto } from './dto/comment-query.dto';
import { CreateCommentDto } from './dto/create-comment.dto';
import { CommentsService } from './comments.service';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@ApiTags('Comments')
@Controller('comments')
@@ -34,6 +38,14 @@ export class CommentsController {
return this.commentsService.findReplies(commentId, query);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
@Get('admin')
async adminList(@Query() query: AdminCommentQueryDto) {
return this.commentsService.findPlatformComments(query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Delete(':commentId')
@@ -42,7 +54,8 @@ export class CommentsController {
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
@Delete('admin/:commentId')
async adminRemove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
return this.commentsService.removeBySuperAdmin(user.email ?? user.sub, commentId);

عرض الملف

@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AuditModule } from '../audit/audit.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PostsModule } from '../posts/posts.module';
import { UsersModule } from '../users/users.module';
import { Comment, CommentSchema } from './schemas/comment.schema';
import { CommentsController } from './comments.controller';
import { CommentsService } from './comments.service';
@@ -12,6 +14,8 @@ import { CommentsRepository } from './comments.repository';
AuditModule,
MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]),
PostsModule,
NotificationsModule,
UsersModule,
],
controllers: [CommentsController],
providers: [CommentsService, CommentsRepository],

عرض الملف

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { Comment, CommentDocument } from './schemas/comment.schema';
@Injectable()
@@ -8,6 +9,14 @@ export class CommentsRepository {
constructor(@InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>) {}
private withActiveFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
return {
...filter,
isDeleted: { $ne: true },
moderationStatus: { $ne: ModerationStatus.HIDDEN },
};
}
private withAdminFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
return {
...filter,
isDeleted: { $ne: true },
@@ -15,15 +24,24 @@ export class CommentsRepository {
}
async create(
payload: { postId: string; authorId: string; content: string; parentCommentId?: string },
payload: {
postId: string;
authorId: string;
content: string;
mentionUsernames?: string[];
parentCommentId?: string;
},
session?: ClientSession,
) {
return this.commentModel.create({
const doc = new this.commentModel({
postId: new Types.ObjectId(payload.postId),
authorId: new Types.ObjectId(payload.authorId),
content: payload.content,
mentionUsernames: payload.mentionUsernames ?? [],
...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}),
}, { session });
});
return session ? doc.save({ session }) : doc.save();
}
async findById(commentId: string): Promise<CommentDocument | null> {
@@ -55,11 +73,31 @@ export class CommentsRepository {
return !!updated;
}
async findMany(filter: FilterQuery<CommentDocument>, skip: number, limit: number) {
async findMany(
filter: FilterQuery<CommentDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
) {
return this.commentModel
.find(this.withActiveFilter(filter))
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
.sort({ createdAt: -1 })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
}
async findManyAdmin(
filter: FilterQuery<CommentDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
) {
return this.commentModel
.find(this.withAdminFilter(filter))
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
@@ -69,6 +107,31 @@ export class CommentsRepository {
return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec();
}
async countAdmin(filter: FilterQuery<CommentDocument>): Promise<number> {
return this.commentModel.countDocuments(this.withAdminFilter(filter)).exec();
}
async updateModerationStatus(
commentId: string,
payload: Pick<Comment, 'moderationStatus' | 'moderationReason'>,
): Promise<CommentDocument | null> {
if (!Types.ObjectId.isValid(commentId)) {
return null;
}
return this.commentModel
.findByIdAndUpdate(
commentId,
{
moderationStatus: payload.moderationStatus,
moderationReason: payload.moderationReason,
},
{ new: true },
)
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
.exec();
}
async countByPost(postId: string): Promise<number> {
return this.commentModel
.countDocuments({ postId: new Types.ObjectId(postId), isDeleted: { $ne: true } })

عرض الملف

@@ -1,16 +1,29 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { AuditService } from '../audit/audit.service';
import { NotificationsService } from '../notifications/notifications.service';
import { PostsRepository } from '../posts/posts.repository';
import { UsersRepository } from '../users/users.repository';
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
import { CommentQueryDto } from './dto/comment-query.dto';
import { CreateCommentDto } from './dto/create-comment.dto';
import { CommentsRepository } from './comments.repository';
@Injectable()
export class CommentsService {
private readonly logger = new Logger(CommentsService.name);
constructor(
private readonly commentsRepository: CommentsRepository,
private readonly postsRepository: PostsRepository,
private readonly auditService: AuditService,
private readonly feedVersionService: FeedVersionService,
private readonly notificationsService: NotificationsService,
private readonly usersRepository: UsersRepository,
) {}
async create(userId: string, dto: CreateCommentDto) {
@@ -19,20 +32,42 @@ export class CommentsService {
throw new NotFoundException('Post not found');
}
let parentRecipientId = '';
if (dto.parentCommentId) {
const parent = await this.commentsRepository.findById(dto.parentCommentId);
if (!parent || parent.postId.toString() !== dto.postId) {
throw new NotFoundException('Parent comment not found');
}
parentRecipientId = parent.authorId.toString();
}
const content = dto.content.trim();
const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, content, userId);
const comment = await this.commentsRepository.create({
postId: dto.postId,
authorId: userId,
content: dto.content,
content,
mentionUsernames: mentionResolution.mentionUsernames,
parentCommentId: dto.parentCommentId,
});
await this.syncCommentsCount(dto.postId);
await this.feedVersionService.bumpGlobalVersion();
const postAuthorId = this.extractEntityId(post.authorId);
const previewText = content.slice(0, 160);
const commentNotificationRecipients = await this.dispatchCommentNotifications(
userId,
postAuthorId,
parentRecipientId,
dto.postId,
previewText,
);
await this.notifyMentionedUsers(
userId,
dto.postId,
mentionResolution.mentionedUsers,
previewText,
commentNotificationRecipients,
);
return comment;
}
@@ -48,6 +83,7 @@ export class CommentsService {
await this.commentsRepository.deleteById(commentId, userId);
await this.syncCommentsCount(comment.postId.toString());
await this.feedVersionService.bumpGlobalVersion();
return { success: true };
}
@@ -59,6 +95,7 @@ export class CommentsService {
await this.commentsRepository.deleteById(commentId, superAdminIdentifier);
await this.syncCommentsCount(comment.postId.toString());
await this.feedVersionService.bumpGlobalVersion();
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'comment_delete',
@@ -70,45 +107,281 @@ export class CommentsService {
}
async findByPost(postId: string, query: CommentQueryDto) {
if (!Types.ObjectId.isValid(postId)) {
throw new BadRequestException('Invalid post id');
}
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const postObjectId = new Types.ObjectId(postId);
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.commentsRepository.findMany({ postId, parentCommentId: { $exists: false } }, skip, limit),
this.commentsRepository.count({ postId, parentCommentId: { $exists: false } }),
this.commentsRepository.findMany(
{
postId: postObjectId,
$or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }],
},
skip,
limit,
sort,
),
this.commentsRepository.count({
postId: postObjectId,
$or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }],
}),
]);
return {
items,
return buildPaginatedResponse(items, {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
offset: skip,
});
}
async findReplies(parentCommentId: string, query: CommentQueryDto) {
if (!Types.ObjectId.isValid(parentCommentId)) {
throw new BadRequestException('Invalid parent comment id');
}
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const parentObjectId = new Types.ObjectId(parentCommentId);
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.commentsRepository.findMany({ parentCommentId }, skip, limit),
this.commentsRepository.count({ parentCommentId }),
this.commentsRepository.findMany({ parentCommentId: parentObjectId }, skip, limit, sort),
this.commentsRepository.count({ parentCommentId: parentObjectId }),
]);
return {
items,
return buildPaginatedResponse(items, {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
offset: skip,
});
}
async findPlatformComments(query: AdminCommentQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const filter: Record<string, unknown> = {};
if (query.postId) {
filter.postId = new Types.ObjectId(query.postId);
}
if (query.authorId) {
filter.authorId = new Types.ObjectId(query.authorId);
}
if (query.q?.trim()) {
filter.content = { $regex: query.q.trim(), $options: 'i' };
}
if (query.moderationStatus) {
filter.moderationStatus = query.moderationStatus;
}
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.commentsRepository.findManyAdmin(filter, skip, limit, sort),
this.commentsRepository.countAdmin(filter),
]);
return buildPaginatedResponse(items, {
page,
limit,
total,
offset: skip,
});
}
async updateModerationStatusBySuperAdmin(
superAdminIdentifier: string,
commentId: string,
dto: { status: ModerationStatus; reason?: string },
) {
const comment = await this.commentsRepository.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
const updated = await this.commentsRepository.updateModerationStatus(commentId, {
moderationStatus: dto.status,
moderationReason: dto.reason?.trim() ?? '',
});
if (!updated) {
throw new NotFoundException('Comment not found');
}
await this.feedVersionService.bumpGlobalVersion();
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'comment_moderation_status_update',
'comment',
commentId,
{
previousStatus: comment.moderationStatus ?? ModerationStatus.ACTIVE,
nextStatus: dto.status,
reason: dto.reason?.trim() ?? '',
},
);
return updated;
}
private async syncCommentsCount(postId: string): Promise<void> {
const totalComments = await this.commentsRepository.countByPost(postId);
await this.postsRepository.setCommentsCount(postId, totalComments);
}
private async dispatchCommentNotifications(
actorId: string,
postAuthorId: string,
parentRecipientId: string,
postId: string,
previewText: string,
): Promise<Set<string>> {
const recipients = new Set<string>();
if (postAuthorId && postAuthorId !== actorId) {
recipients.add(postAuthorId);
}
if (parentRecipientId && parentRecipientId !== actorId) {
recipients.add(parentRecipientId);
}
for (const recipientId of recipients) {
try {
await this.notificationsService.createCommentNotification(actorId, recipientId, postId, {
resourceType: 'post',
previewText,
});
} catch (error) {
this.logger.warn(
`Comment notification failed for actor=${actorId} recipient=${recipientId}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
}
}
return recipients;
}
private normalizeMentionUsernames(input: string[] = []): string[] {
return Array.from(
new Set(
input
.map((username) => username?.trim().replace(/^@+/, '').toLowerCase())
.filter((username): username is string => !!username),
),
);
}
private extractMentions(content: string): string[] {
const matches = content.match(/@[\p{L}\p{N}_.]+/gu) ?? [];
return this.normalizeMentionUsernames(matches.map((item) => item.replace('@', '')));
}
private async resolveMentionTargets(
explicitMentionUsernames: string[] | undefined,
content: string,
authorId: string,
): Promise<{
mentionUsernames: string[];
mentionedUsers: Array<{ id: string; username: string }>;
}> {
const mergedMentionUsernames = Array.from(
new Set([
...this.extractMentions(content),
...this.normalizeMentionUsernames(explicitMentionUsernames ?? []),
]),
);
if (mergedMentionUsernames.length > 30) {
throw new BadRequestException('You can mention up to 30 users only');
}
if (!mergedMentionUsernames.length) {
return { mentionUsernames: [], mentionedUsers: [] };
}
const users = await this.usersRepository.findByUsernames(mergedMentionUsernames);
const userByUsername = new Map(
users.map((user) => [user.username.toLowerCase(), { id: user.id, username: user.username.toLowerCase() }]),
);
const mentionedUsers = mergedMentionUsernames
.map((username) => userByUsername.get(username))
.filter((user): user is { id: string; username: string } => !!user)
.filter((user) => user.id !== authorId);
return {
mentionUsernames: mentionedUsers.map((user) => user.username),
mentionedUsers,
};
}
private async notifyMentionedUsers(
actorId: string,
postId: string,
mentionedUsers: Array<{ id: string; username: string }>,
previewText: string,
excludedRecipientIds: Set<string>,
): Promise<void> {
if (!mentionedUsers.length) {
return;
}
for (const mentionedUser of mentionedUsers) {
if (excludedRecipientIds.has(mentionedUser.id)) {
continue;
}
try {
await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, {
resourceType: 'comment',
previewText,
deepLink: `/posts/${postId}`,
});
} catch (error) {
this.logger.warn(
`Comment mention notification failed for actor=${actorId} recipient=${mentionedUser.id}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
}
}
}
private extractEntityId(value: unknown): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (value instanceof Types.ObjectId) {
return value.toString();
}
if (typeof value === 'object') {
const candidate = value as { _id?: unknown; id?: unknown };
if (candidate._id instanceof Types.ObjectId) {
return candidate._id.toString();
}
if (typeof candidate._id === 'string') {
return candidate._id;
}
if (typeof candidate.id === 'string') {
return candidate.id;
}
}
return '';
}
}

عرض الملف

@@ -0,0 +1,26 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsMongoId, IsOptional, IsString } from 'class-validator';
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
import { CommentQueryDto } from './comment-query.dto';
export class AdminCommentQueryDto extends CommentQueryDto {
@ApiPropertyOptional({ description: 'Search comment content' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ description: 'Optional post filter' })
@IsOptional()
@IsMongoId()
postId?: string;
@ApiPropertyOptional({ description: 'Optional author filter' })
@IsOptional()
@IsMongoId()
authorId?: string;
@ApiPropertyOptional({ enum: ModerationStatus, description: 'Optional moderation status filter' })
@IsOptional()
@IsEnum(ModerationStatus)
moderationStatus?: ModerationStatus;
}

عرض الملف

@@ -1,3 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
export class CommentQueryDto extends PaginationQueryDto {}
export class CommentQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({
description: 'Use asc to display oldest comments first, or desc for newest first',
default: 'desc',
})
declare sortOrder: PaginationQueryDto['sortOrder'];
}

عرض الملف

@@ -1,5 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsMongoId, IsOptional, IsString, Length } from 'class-validator';
import { Transform } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsMongoId, IsOptional, IsString, Length } from 'class-validator';
import { toStringArray } from '../../../common/utils/array-transform.util';
export class CreateCommentDto {
@ApiProperty()
@@ -15,4 +17,13 @@ export class CreateCommentDto {
@IsOptional()
@IsMongoId()
parentCommentId?: string;
@ApiPropertyOptional({ type: [String], description: 'Mention usernames like rami_sabry (max 30)' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(30)
@IsString({ each: true })
@Length(1, 30, { each: true })
mentionUsernames?: string[];
}

عرض الملف

@@ -1,5 +1,6 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
import { Post } from '../../posts/schemas/post.schema';
import { User } from '../../users/schemas/user.schema';
@@ -19,6 +20,20 @@ export class Comment {
@Prop({ required: true, maxlength: 1000 })
content!: string;
@Prop({ type: [String], default: [] })
mentionUsernames!: string[];
@Prop({
type: String,
enum: Object.values(ModerationStatus),
default: ModerationStatus.ACTIVE,
index: true,
})
moderationStatus!: ModerationStatus;
@Prop({ default: '', maxlength: 300 })
moderationReason!: string;
@Prop({ default: false, index: true })
isDeleted!: boolean;
@@ -32,3 +47,4 @@ export class Comment {
export const CommentSchema = SchemaFactory.createForClass(Comment);
CommentSchema.index({ postId: 1, createdAt: -1 });
CommentSchema.index({ postId: 1, parentCommentId: 1, isDeleted: 1, createdAt: -1 });
CommentSchema.index({ moderationStatus: 1, createdAt: -1 });

عرض الملف

@@ -1,7 +1,8 @@
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { IsBoolean, IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import { PostType } from '../../../common/enums/post-type.enum';
import { toBoolean } from '../../../common/utils/query-transform.util';
export class FeedQueryDto extends PaginationQueryDto {
@IsOptional()
@@ -9,7 +10,7 @@ export class FeedQueryDto extends PaginationQueryDto {
preferredPostType?: PostType;
@IsOptional()
@Type(() => Boolean)
@Transform(toBoolean)
@IsBoolean()
followingOnly?: boolean;
@@ -19,4 +20,16 @@ export class FeedQueryDto extends PaginationQueryDto {
@Min(1)
@Max(500)
radiusKm?: number;
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
includeSuggestions?: boolean;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(2)
@Max(10)
suggestionInterval?: number;
}

عرض الملف

@@ -21,7 +21,7 @@ export class FeedController {
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('trending')
async trending(@Query() query: FeedQueryDto) {
return this.feedService.getTrending(query);
async trending(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) {
return this.feedService.getTrending(user.sub, query);
}
}

عرض الملف

@@ -1,7 +1,11 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { FollowsModule } from '../follows/follows.module';
import { LikesModule } from '../likes/likes.module';
import { MarketplaceModule } from '../marketplace/marketplace.module';
import { Follow, FollowSchema } from '../follows/schemas/follow.schema';
import { Post, PostSchema } from '../posts/schemas/post.schema';
import { SavesModule } from '../saves/saves.module';
import { UsersModule } from '../users/users.module';
import { FeedController } from './feed.controller';
import { FeedService } from './feed.service';
@@ -10,6 +14,10 @@ import { FeedRepository } from './feed.repository';
@Module({
imports: [
UsersModule,
LikesModule,
SavesModule,
FollowsModule,
MarketplaceModule,
MongooseModule.forFeature([
{ name: Post.name, schema: PostSchema },
{ name: Follow.name, schema: FollowSchema },

عرض الملف

@@ -42,11 +42,23 @@ export class FeedRepository {
.exec();
}
async findTrendingPublicPosts(skip: number, limit: number): Promise<PostDocument[]> {
async findTrendingPublicPosts(
filter: FilterQuery<PostDocument>,
skip: number,
limit: number,
): Promise<PostDocument[]> {
return this.postModel
.find({ visibility: 'public', isDeleted: { $ne: true } })
.find({ ...filter, isDeleted: { $ne: true } })
.populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' })
.sort({ likesCount: -1, commentsCount: -1, savesCount: -1, createdAt: -1 })
.sort({
shareCount: -1,
likesCount: -1,
commentsCount: -1,
savesCount: -1,
viewCount: -1,
playCount: -1,
createdAt: -1,
})
.skip(skip)
.limit(limit)
.exec();

عرض الملف

@@ -1,21 +1,97 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Types } from 'mongoose';
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
import { PostType } from '../../common/enums/post-type.enum';
import { PostVisibility } from '../../common/enums/post-visibility.enum';
import { UsersRepository } from '../users/users.repository';
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { AppCacheService } from '../../infrastructure/cache/app-cache.service';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { FollowsService } from '../follows/follows.service';
import { LikesRepository } from '../likes/likes.repository';
import { MarketplaceService } from '../marketplace/marketplace.service';
import { SavesRepository } from '../saves/saves.repository';
import { UserDocument } from '../users/schemas/user.schema';
import { UsersRepository } from '../users/users.repository';
import { FeedQueryDto } from './dto/feed-query.dto';
import { FeedRepository } from './feed.repository';
type FeedPostItem = Record<string, unknown> & {
feedItemType: 'post';
feedScore?: number;
likedByMe: boolean;
savedByMe: boolean;
followingAuthor: boolean;
isOwnPost: boolean;
canComment: boolean;
canMessage: boolean;
engagement: {
likesCount: number;
commentsCount: number;
savesCount: number;
shareCount: number;
viewCount: number;
playCount: number;
};
};
type FeedCardItem =
| {
id: string;
feedItemType: 'suggested_users';
title: string;
subtitle: string;
items: Array<Record<string, unknown>>;
}
| {
id: string;
feedItemType: 'featured_marketplace';
title: string;
subtitle: string;
listings: Array<Record<string, unknown>>;
musicalInstruments: Array<Record<string, unknown>>;
instruments: Array<Record<string, unknown>>;
repairShops: Array<Record<string, unknown>>;
};
@Injectable()
export class FeedService {
constructor(
private readonly feedRepository: FeedRepository,
private readonly usersRepository: UsersRepository,
private readonly cacheService: AppCacheService,
private readonly feedVersionService: FeedVersionService,
private readonly configService: ConfigService,
private readonly likesRepository: LikesRepository,
private readonly savesRepository: SavesRepository,
private readonly followsService: FollowsService,
private readonly marketplaceService: MarketplaceService,
) {}
async getMyFeed(currentUserId: string, query: FeedQueryDto) {
const cacheEnabled =
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0;
const includeSuggestions = this.shouldIncludeSuggestions(query);
const cacheKey = this.buildCacheKey('me', {
currentUserId,
globalVersion,
page: query.page ?? 1,
limit: query.limit ?? 20,
cursor: query.cursor ?? '',
followingOnly: query.followingOnly ?? false,
radiusKm: query.radiusKm ?? 30,
preferredPostType: query.preferredPostType ?? '',
includeSuggestions,
suggestionInterval: query.suggestionInterval ?? 4,
});
if (cacheEnabled) {
const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey);
if (cached) {
return cached;
}
}
const currentUser = await this.usersRepository.findById(currentUserId);
if (!currentUser) {
throw new NotFoundException('Current user not found');
@@ -26,20 +102,10 @@ export class FeedService {
const page = query.page ?? 1;
const followingOnly = query.followingOnly ?? false;
const radiusKm = query.radiusKm ?? 30;
const skip = cursorOffset ?? (page - 1) * limit;
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
const visibleAuthorIds = followingOnly ? [currentUserId, ...followingIds] : null;
const filter: Record<string, unknown> = {
$or: [
{ visibility: PostVisibility.PUBLIC },
{ authorId: new Types.ObjectId(currentUserId) },
],
};
if (visibleAuthorIds) {
filter.authorId = { $in: visibleAuthorIds.map((id) => new Types.ObjectId(id)) };
}
const filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly);
const candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
@@ -64,48 +130,268 @@ export class FeedService {
.sort(
(a, b) =>
b.score - a.score ||
new Date((b.post as any).createdAt ?? 0).getTime() - new Date((a.post as any).createdAt ?? 0).getTime(),
new Date((b.post as any).createdAt ?? 0).getTime() -
new Date((a.post as any).createdAt ?? 0).getTime(),
);
const total = scored.length;
const skip = cursorOffset ?? (page - 1) * limit;
const items = scored.slice(skip, skip + limit).map((entry) => ({
...entry.post.toObject(),
const pagedPosts = scored.slice(skip, skip + limit).map((entry) => ({
...(entry.post.toObject() as unknown as Record<string, unknown>),
feedScore: Number(entry.score.toFixed(3)),
}));
const nextOffset = skip + items.length;
const decoratedPosts = await this.decoratePostsForViewer(currentUserId, pagedPosts, followingIds);
const items = includeSuggestions
? await this.mixHomeFeedItems(currentUserId, decoratedPosts, query.suggestionInterval ?? 4)
: decoratedPosts;
const nextOffset = skip + pagedPosts.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
return {
items,
const result = buildPaginatedResponse(items, {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
offset: skip,
currentCursor: query.cursor ?? null,
nextCursor,
};
mode: 'cursor',
});
if (cacheEnabled) {
await this.cacheService.set(
cacheKey,
result,
this.configService.get<number>('feedCache.userFeedTtlSeconds', { infer: true }) ?? 15,
);
}
return result;
}
async getTrending(query: FeedQueryDto) {
async getTrending(currentUserId: string, query: FeedQueryDto) {
const cacheEnabled =
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0;
const cacheKey = this.buildCacheKey('trending', {
currentUserId,
globalVersion,
page: query.page ?? 1,
limit: query.limit ?? 20,
cursor: query.cursor ?? '',
preferredPostType: query.preferredPostType ?? '',
});
if (cacheEnabled) {
const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey);
if (cached) {
return cached;
}
}
const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor);
const page = query.page ?? 1;
const skip = cursorOffset ?? (page - 1) * limit;
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
const trendingFilter: Record<string, unknown> = { visibility: PostVisibility.PUBLIC };
if (query.preferredPostType) {
trendingFilter.postType = query.preferredPostType;
}
const [items, total] = await Promise.all([
this.feedRepository.findTrendingPublicPosts(skip, limit),
this.feedRepository.count({ visibility: PostVisibility.PUBLIC }),
const [rows, total] = await Promise.all([
this.feedRepository.findTrendingPublicPosts(trendingFilter, skip, limit),
this.feedRepository.count(trendingFilter),
]);
const nextOffset = skip + items.length;
const decoratedPosts = await this.decoratePostsForViewer(
currentUserId,
rows.map((item) => item.toObject() as unknown as Record<string, unknown>),
followingIds,
);
const nextOffset = skip + rows.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
return {
items,
const result = buildPaginatedResponse(decoratedPosts, {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
offset: skip,
currentCursor: query.cursor ?? null,
nextCursor,
mode: 'cursor',
});
if (cacheEnabled) {
await this.cacheService.set(
cacheKey,
result,
this.configService.get<number>('feedCache.trendingTtlSeconds', { infer: true }) ?? 30,
);
}
return result;
}
private async decoratePostsForViewer(
currentUserId: string,
items: Array<Record<string, unknown>>,
followingIds: string[],
): Promise<FeedPostItem[]> {
const postIds = items
.map((item) => this.extractEntityId(item._id ?? item.id))
.filter(Boolean);
const followingSet = new Set(followingIds);
const [likedPostIds, savedPostIds] = await Promise.all([
this.likesRepository.findLikedPostIds(currentUserId, postIds),
this.savesRepository.findSavedPostIds(currentUserId, postIds),
]);
const likedSet = new Set(likedPostIds);
const savedSet = new Set(savedPostIds);
return items.map((item) => {
const postId = this.extractEntityId(item._id ?? item.id);
const authorId = this.extractEntityId(item.authorId);
const likesCount = Number(item.likesCount ?? 0);
const commentsCount = Number(item.commentsCount ?? 0);
const savesCount = Number(item.savesCount ?? 0);
const shareCount = Number(item.shareCount ?? 0);
const viewCount = Number(item.viewCount ?? 0);
const playCount = Number(item.playCount ?? 0);
return {
...item,
id: postId,
feedItemType: 'post',
likedByMe: likedSet.has(postId),
savedByMe: savedSet.has(postId),
followingAuthor: !!authorId && followingSet.has(authorId),
isOwnPost: authorId === currentUserId,
canComment: true,
canMessage: !!authorId && authorId !== currentUserId,
engagement: {
likesCount,
commentsCount,
savesCount,
shareCount,
viewCount,
playCount,
},
};
});
}
private async mixHomeFeedItems(
currentUserId: string,
posts: FeedPostItem[],
suggestionInterval: number,
): Promise<Array<FeedPostItem | FeedCardItem>> {
const cards = await this.buildHomeCards(currentUserId);
if (!cards.length) {
return posts;
}
const result: Array<FeedPostItem | FeedCardItem> = [];
let cardIndex = 0;
for (let index = 0; index < posts.length; index += 1) {
result.push(posts[index]);
if ((index + 1) % suggestionInterval === 0 && cardIndex < cards.length) {
result.push(cards[cardIndex]);
cardIndex += 1;
}
}
while (cardIndex < cards.length) {
result.push(cards[cardIndex]);
cardIndex += 1;
}
return result;
}
private async buildHomeCards(currentUserId: string): Promise<FeedCardItem[]> {
const [suggestions, listings, instruments, repairShops] = await Promise.all([
this.followsService.getSuggestions(currentUserId, {
page: 1,
limit: 5,
}),
this.marketplaceService.getPublicListings({
page: 1,
limit: 3,
isActive: true,
} as any),
this.marketplaceService.getPublicInstruments({
page: 1,
limit: 3,
isActive: true,
} as any),
this.marketplaceService.getPublicRepairShops({
page: 1,
limit: 2,
isActive: true,
} as any),
]);
const cards: FeedCardItem[] = [];
if (Array.isArray(suggestions.items) && suggestions.items.length > 0) {
cards.push({
id: `suggested-users:${currentUserId}`,
feedItemType: 'suggested_users',
title: 'Suggested creators',
subtitle: 'People you may want to follow',
items: suggestions.items.map((entry) => ({
...entry,
following: false,
})),
});
}
if (
(listings.items?.length ?? 0) > 0 ||
(instruments.items?.length ?? 0) > 0 ||
(repairShops.items?.length ?? 0) > 0
) {
cards.push({
id: `featured-marketplace:${currentUserId}`,
feedItemType: 'featured_marketplace',
title: 'Explore marketplace',
subtitle: 'Featured listings, musical instruments, and repair shops',
listings: (listings.items ?? []) as unknown as Array<Record<string, unknown>>,
musicalInstruments: (instruments.items ?? []) as unknown as Array<Record<string, unknown>>,
instruments: (instruments.items ?? []) as unknown as Array<Record<string, unknown>>,
repairShops: (repairShops.items ?? []) as unknown as Array<Record<string, unknown>>,
});
}
return cards;
}
private buildVisiblePostsFilter(
currentUserId: string,
followingIds: string[],
followingOnly: boolean,
): Record<string, unknown> {
const currentUserObjectId = new Types.ObjectId(currentUserId);
const followingObjectIds = followingIds.map((id) => new Types.ObjectId(id));
if (followingOnly) {
return {
$or: [
{ authorId: currentUserObjectId },
{
authorId: { $in: followingObjectIds },
visibility: { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] },
},
],
};
}
return {
$or: [
{ visibility: PostVisibility.PUBLIC },
{ authorId: currentUserObjectId },
{
authorId: { $in: followingObjectIds },
visibility: PostVisibility.FOLLOWERS,
},
],
};
}
@@ -113,13 +399,13 @@ export class FeedService {
currentUser: UserDocument;
currentUserId: string;
followingIds: string[];
post: any;
post: Record<string, any>;
preferredPostType?: PostType;
radiusKm: number;
}): number {
const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input;
const author: any = post.authorId;
const authorId = typeof author === 'string' ? author : author?._id?.toString?.() ?? '';
const author = post.authorId;
const authorId = this.extractEntityId(author);
const isOwnPost = authorId === currentUserId;
const isFollowing = followingIds.includes(authorId);
@@ -127,10 +413,16 @@ export class FeedService {
const ageHours = ageMs / (1000 * 60 * 60);
const freshness = Math.max(0, 36 - ageHours);
const engagement = post.likesCount * 3 + post.commentsCount * 4 + post.savesCount * 5;
const engagement =
Number(post.likesCount ?? 0) * 3 +
Number(post.commentsCount ?? 0) * 4 +
Number(post.savesCount ?? 0) * 5 +
Number(post.shareCount ?? 0) * 6 +
Number(post.viewCount ?? 0) * 0.15 +
Number(post.playCount ?? 0) * 0.25;
const hashtagMatches = this.intersectionCount(
this.buildPreferenceTokens(currentUser),
(post.hashtags ?? []).map((x: string) => x.toLowerCase()),
(post.hashtags ?? []).map((value: string) => value.toLowerCase()),
);
const distanceKm = this.computeDistanceKm(
@@ -209,4 +501,45 @@ export class FeedService {
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthKm * c;
}
private buildCacheKey(scope: string, input: Record<string, unknown>): string {
return `feed:${scope}:${JSON.stringify(input)}`;
}
private shouldIncludeSuggestions(query: FeedQueryDto): boolean {
return (
query.includeSuggestions === true &&
!(query.cursor ?? '').trim() &&
(query.page ?? 1) === 1
);
}
private extractEntityId(value: unknown): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (value instanceof Types.ObjectId) {
return value.toString();
}
if (typeof value === 'object') {
const candidate = value as { _id?: unknown; id?: unknown };
if (candidate._id instanceof Types.ObjectId) {
return candidate._id.toString();
}
if (typeof candidate._id === 'string') {
return candidate._id;
}
if (typeof candidate.id === 'string') {
return candidate.id;
}
}
return '';
}
}

عرض الملف

@@ -25,14 +25,14 @@ export class FollowsController {
@UseGuards(JwtAuthGuard)
@Get('followers/:userId')
async followers(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
return this.followsService.getFollowers(userId, query.page, query.limit);
return this.followsService.getFollowers(userId, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('following/:userId')
async following(@Param('userId') userId: string, @Query() query: PaginationQueryDto) {
return this.followsService.getFollowing(userId, query.page, query.limit);
return this.followsService.getFollowing(userId, query);
}
@ApiBearerAuth()
@@ -47,6 +47,6 @@ export class FollowsController {
@Get('suggestions')
@Throttle(60, 60_000)
async suggestions(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
return this.followsService.getSuggestions(user.sub, query.page, query.limit);
return this.followsService.getSuggestions(user.sub, query);
}
}

عرض الملف

@@ -38,12 +38,17 @@ export class FollowsRepository {
await this.followModel.findByIdAndDelete(id, { session }).exec();
}
async findMany(filter: FilterQuery<FollowDocument>, skip: number, limit: number): Promise<FollowDocument[]> {
async findMany(
filter: FilterQuery<FollowDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<FollowDocument[]> {
return this.followModel
.find(filter)
.populate({ path: 'followerId', select: 'name username stageName avatar isVerified isDisabled' })
.populate({ path: 'followingId', select: 'name username stageName avatar isVerified isDisabled' })
.sort({ createdAt: -1 })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();

عرض الملف

@@ -26,6 +26,7 @@ describe('FollowsService', () => {
followsRepository as any,
usersRepository as any,
outboxService as any,
{ bumpGlobalVersion: jest.fn().mockResolvedValue(1) } as any,
);
await expect(service.toggleFollow(currentUserId, { targetUserId })).resolves.toEqual({

عرض الملف

@@ -1,5 +1,9 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { OutboxService } from '../outbox/outbox.service';
import { UsersRepository } from '../users/users.repository';
import { UserDocument } from '../users/schemas/user.schema';
@@ -14,6 +18,7 @@ export class FollowsService {
private readonly followsRepository: FollowsRepository,
private readonly usersRepository: UsersRepository,
private readonly outboxService: OutboxService,
private readonly feedVersionService: FeedVersionService,
) {}
async toggleFollow(currentUserId: string, dto: ToggleFollowDto) {
@@ -37,11 +42,13 @@ export class FollowsService {
if (existing) {
await this.followsRepository.deleteById(existing.id);
await this.syncFollowCounts(currentUserId, targetUserId);
await this.feedVersionService.bumpGlobalVersion();
return { following: false };
}
const follow = await this.followsRepository.create(currentUserId, targetUserId);
await this.syncFollowCounts(currentUserId, targetUserId);
await this.feedVersionService.bumpGlobalVersion();
try {
await this.outboxService.enqueueFollowNotification(currentUserId, targetUserId, follow.id);
@@ -56,36 +63,40 @@ export class FollowsService {
return { following: true };
}
async getFollowers(userId: string, page = 1, limit = 20) {
async getFollowers(userId: string, query: PaginationQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.followsRepository.findMany({ followingId: userId }, skip, limit),
this.followsRepository.findMany({ followingId: userId }, skip, limit, sort),
this.followsRepository.count({ followingId: userId }),
]);
return {
items,
return buildPaginatedResponse(items, {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
offset: skip,
});
}
async getFollowing(userId: string, page = 1, limit = 20) {
async getFollowing(userId: string, query: PaginationQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.followsRepository.findMany({ followerId: userId }, skip, limit),
this.followsRepository.findMany({ followerId: userId }, skip, limit, sort),
this.followsRepository.count({ followerId: userId }),
]);
return {
items,
return buildPaginatedResponse(items, {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
offset: skip,
});
}
async getFollowStatus(currentUserId: string, targetUserId: string) {
@@ -104,7 +115,9 @@ export class FollowsService {
};
}
async getSuggestions(currentUserId: string, page = 1, limit = 20) {
async getSuggestions(currentUserId: string, query: PaginationQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const currentUser = await this.usersRepository.findById(currentUserId);
if (!currentUser) {
throw new NotFoundException('Current user not found');
@@ -128,6 +141,10 @@ export class FollowsService {
}))
.sort((a, b) => b.score - a.score || b.user.followersCount - a.user.followersCount);
if (query.sortOrder === 'asc') {
ranked.reverse();
}
const total = ranked.length;
const skip = (page - 1) * limit;
const items = ranked.slice(skip, skip + limit).map((entry) => ({
@@ -136,13 +153,12 @@ export class FollowsService {
reasons: this.buildSuggestionReasons(currentUser, entry.user),
}));
return {
items,
return buildPaginatedResponse(items, {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
offset: skip,
});
}
private calculateSuggestionScore(currentUser: UserDocument, candidate: UserDocument): number {

عرض الملف

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { CommentsModule } from '../comments/comments.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PostsModule } from '../posts/posts.module';
import { Like, LikeSchema } from './schemas/like.schema';
import { LikesController } from './likes.controller';
@@ -12,9 +13,10 @@ import { LikesService } from './likes.service';
MongooseModule.forFeature([{ name: Like.name, schema: LikeSchema }]),
PostsModule,
CommentsModule,
NotificationsModule,
],
controllers: [LikesController],
providers: [LikesService, LikesRepository],
exports: [LikesService],
exports: [LikesService, LikesRepository],
})
export class LikesModule {}

عرض الملف

@@ -25,6 +25,24 @@ export class LikesRepository {
});
}
async findLikedPostIds(userId: string, postIds: string[]): Promise<string[]> {
if (!postIds.length) {
return [];
}
const rows = await this.likeModel
.find({
userId: new Types.ObjectId(userId),
targetType: 'post',
targetId: { $in: postIds.map((id) => new Types.ObjectId(id)) },
})
.select({ targetId: 1 })
.lean()
.exec();
return rows.map((row) => row.targetId.toString());
}
async deleteById(id: string): Promise<void> {
await this.likeModel.findByIdAndDelete(id).exec();
}

عرض الملف

@@ -16,6 +16,8 @@ describe('LikesService', () => {
likesRepository as any,
postsRepository as any,
commentsRepository as any,
{ bumpGlobalVersion: jest.fn() } as any,
{ createLikeNotification: jest.fn() } as any,
);
await expect(

عرض الملف

@@ -1,4 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CommentsRepository } from '../comments/comments.repository';
import { PostsRepository } from '../posts/posts.repository';
import { LikesRepository } from './likes.repository';
@@ -6,10 +9,14 @@ import { ToggleLikeDto } from './dto/toggle-like.dto';
@Injectable()
export class LikesService {
private readonly logger = new Logger(LikesService.name);
constructor(
private readonly likesRepository: LikesRepository,
private readonly postsRepository: PostsRepository,
private readonly commentsRepository: CommentsRepository,
private readonly feedVersionService: FeedVersionService,
private readonly notificationsService: NotificationsService,
) {}
async toggle(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
@@ -19,6 +26,7 @@ export class LikesService {
async like(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
await this.assertTargetExists(dto);
const notificationContext = await this.resolveNotificationContext(dto);
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
if (existing) {
@@ -29,6 +37,26 @@ export class LikesService {
if (dto.targetType === 'post') {
await this.postsRepository.incrementLikesCount(dto.targetId, 1);
}
await this.feedVersionService.bumpGlobalVersion();
if (notificationContext.recipientId && notificationContext.recipientId !== userId) {
try {
await this.notificationsService.createLikeNotification(
userId,
notificationContext.recipientId,
dto.targetId,
{
resourceType: dto.targetType,
previewText: notificationContext.previewText,
},
);
} catch (error) {
this.logger.warn(
`Like notification failed for actor=${userId} recipient=${notificationContext.recipientId}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
}
}
return { liked: true, targetId: dto.targetId, targetType: dto.targetType };
}
@@ -45,6 +73,7 @@ export class LikesService {
if (dto.targetType === 'post') {
await this.postsRepository.incrementLikesCount(dto.targetId, -1);
}
await this.feedVersionService.bumpGlobalVersion();
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
}
@@ -75,4 +104,51 @@ export class LikesService {
const comment = await this.commentsRepository.findById(dto.targetId);
return !!comment;
}
private async resolveNotificationContext(
dto: ToggleLikeDto,
): Promise<{ recipientId: string; previewText: string }> {
if (dto.targetType === 'post') {
const post = await this.postsRepository.findById(dto.targetId);
return {
recipientId: this.extractEntityId(post?.authorId),
previewText: (post?.content ?? '').slice(0, 140),
};
}
const comment = await this.commentsRepository.findById(dto.targetId);
return {
recipientId: comment?.authorId?.toString?.() ?? '',
previewText: (comment?.content ?? '').slice(0, 140),
};
}
private extractEntityId(value: unknown): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (value instanceof Types.ObjectId) {
return value.toString();
}
if (typeof value === 'object') {
const candidate = value as { _id?: unknown; id?: unknown };
if (candidate._id instanceof Types.ObjectId) {
return candidate._id.toString();
}
if (typeof candidate._id === 'string') {
return candidate._id;
}
if (typeof candidate.id === 'string') {
return candidate.id;
}
}
return '';
}
}

عرض الملف

@@ -1,8 +1,9 @@
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
@@ -10,6 +11,10 @@ import {
MaxLength,
Min,
} from 'class-validator';
import { MarketplaceListingCondition } from '../enums/marketplace-listing-condition.enum';
import { MarketplaceListingCategory } from '../enums/marketplace-listing-category.enum';
import { toStringArray } from '../../../common/utils/array-transform.util';
import { toBoolean } from '../../../common/utils/query-transform.util';
export class CreateInstrumentDto {
@IsString()
@@ -38,13 +43,27 @@ export class CreateInstrumentDto {
quantity!: number;
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(5)
@IsString({ each: true })
imageUrls?: string[];
@IsOptional()
@Type(() => Boolean)
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsEnum(MarketplaceListingCondition)
condition?: MarketplaceListingCondition;
@IsOptional()
@IsString()
@MaxLength(80)
instrumentType?: string;
@IsOptional()
@IsEnum(MarketplaceListingCategory)
listingCategory?: MarketplaceListingCategory;
}

عرض الملف

@@ -0,0 +1,74 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsNumber,
IsOptional,
IsString,
IsUrl,
Length,
Max,
Min,
} from 'class-validator';
import { toStringArray } from '../../../common/utils/array-transform.util';
import { toBoolean } from '../../../common/utils/query-transform.util';
export class CreateRepairShopDto {
@IsString()
@Length(2, 120)
name!: string;
@IsOptional()
@IsString()
@Length(0, 2000)
description?: string;
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(20)
@IsString({ each: true })
services?: string[];
@IsOptional()
@IsString()
@Length(0, 40)
phone?: string;
@IsOptional()
@IsString()
@Length(0, 40)
whatsapp?: string;
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(8)
@IsUrl({ require_tld: false }, { each: true })
imageUrls?: string[];
@IsOptional()
@IsString()
@Length(0, 160)
location?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number;
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
}

عرض الملف

@@ -1,29 +1,60 @@
import { Type } from 'class-transformer';
import { IsBoolean, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { toBoolean } from '../../../common/utils/query-transform.util';
import { MarketplaceListingCondition } from '../enums/marketplace-listing-condition.enum';
import { MarketplaceListingCategory } from '../enums/marketplace-listing-category.enum';
export const INSTRUMENT_SORT_FIELDS = ['createdAt', 'updatedAt', 'price', 'title'] as const;
export type InstrumentSortField = (typeof INSTRUMENT_SORT_FIELDS)[number];
export class InstrumentQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Search by title or description' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
minPrice?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
maxPrice?: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Type(() => Boolean)
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ enum: MarketplaceListingCondition })
@IsOptional()
@IsEnum(MarketplaceListingCondition)
condition?: MarketplaceListingCondition;
@ApiPropertyOptional({ description: 'Filter by instrument type such as oud, piano, violin' })
@IsOptional()
@IsString()
instrumentType?: string;
@ApiPropertyOptional({ enum: INSTRUMENT_SORT_FIELDS, default: 'createdAt' })
@IsOptional()
@IsEnum(INSTRUMENT_SORT_FIELDS)
sortBy?: InstrumentSortField;
@ApiPropertyOptional({ enum: MarketplaceListingCategory })
@IsOptional()
@IsEnum(MarketplaceListingCategory)
listingCategory?: MarketplaceListingCategory;
@IsOptional()
@Type(() => Number)
@IsNumber()

عرض الملف

@@ -0,0 +1,36 @@
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';
export class MarketplaceHomeQueryDto {
@ApiPropertyOptional({ minimum: 1, maximum: 20, default: 6 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(20)
listingsLimit?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 20, default: 6 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(20)
instrumentsLimit?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 20, default: 4 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(20)
repairShopsLimit?: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
onlyActive?: boolean;
}

عرض الملف

@@ -0,0 +1,33 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { toBoolean } from '../../../common/utils/query-transform.util';
export const REPAIR_SHOP_SORT_FIELDS = ['createdAt', 'updatedAt', 'name'] as const;
export type RepairShopSortField = (typeof REPAIR_SHOP_SORT_FIELDS)[number];
export class RepairShopQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Search by name, description, services, or location' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ enum: REPAIR_SHOP_SORT_FIELDS, default: 'createdAt' })
@IsOptional()
@IsEnum(REPAIR_SHOP_SORT_FIELDS)
sortBy?: RepairShopSortField;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(200)
limit?: number;
}

عرض الملف

@@ -0,0 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateMarketplaceStatusDto {
@ApiProperty({ example: false })
@IsBoolean()
isActive!: boolean;
@ApiPropertyOptional({ example: 'Listing disabled pending verification' })
@IsOptional()
@IsString()
@MaxLength(1200)
reason?: string;
}

عرض الملف

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRepairShopDto } from './create-repair-shop.dto';
export class UpdateRepairShopDto extends PartialType(CreateRepairShopDto) {}

عرض الملف

@@ -0,0 +1,51 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsNumber,
IsOptional,
IsString,
IsUrl,
Length,
Max,
Min,
} from 'class-validator';
import { toStringArray } from '../../../common/utils/array-transform.util';
export class UpdateShopProfileDto {
@IsOptional()
@IsString()
@Length(2, 120)
shopName?: string;
@IsOptional()
@IsString()
@Length(0, 2000)
shopDescription?: string;
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(8)
@IsUrl({ require_tld: false }, { each: true })
shopImageUrls?: string[];
@IsOptional()
@IsString()
@Length(0, 160)
shopLocation?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
shopLatitude?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
shopLongitude?: number;
}

عرض الملف

@@ -0,0 +1,7 @@
export enum MarketplaceListingCategory {
MUSICAL_INSTRUMENT = 'musical_instrument',
ACCESSORY = 'accessory',
AUDIO_GEAR = 'audio_gear',
SHEET_MUSIC = 'sheet_music',
OTHER = 'other',
}

عرض الملف

@@ -0,0 +1,6 @@
export enum MarketplaceListingCondition {
NEW = 'new',
LIKE_NEW = 'like_new',
USED = 'used',
REFURBISHED = 'refurbished',
}

عرض الملف

@@ -1,52 +1,461 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
import { Throttle } from '../../common/decorators/throttle.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { UserRole } from '../../common/enums/user-role.enum';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { CreateInstrumentDto } from './dto/create-instrument.dto';
import { CreateRepairShopDto } from './dto/create-repair-shop.dto';
import { InstrumentQueryDto } from './dto/instrument-query.dto';
import { MarketplaceHomeQueryDto } from './dto/marketplace-home-query.dto';
import { RepairShopQueryDto } from './dto/repair-shop-query.dto';
import { UpdateShopProfileDto } from './dto/update-shop-profile.dto';
import { UpdateInstrumentDto } from './dto/update-instrument.dto';
import { UpdateMarketplaceStatusDto } from './dto/update-marketplace-status.dto';
import { UpdateRepairShopDto } from './dto/update-repair-shop.dto';
import { MarketplaceService } from './marketplace.service';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@ApiTags('Marketplace')
@Controller('marketplace')
export class MarketplaceController {
constructor(private readonly marketplaceService: MarketplaceService) {}
@Get('home')
async getMarketplaceHome(@Query() query: MarketplaceHomeQueryDto) {
return this.marketplaceService.getHome(query);
}
@Get('listings')
async listPublicListings(@Query() query: InstrumentQueryDto) {
return this.marketplaceService.getPublicListings(query);
}
@Get('listings/:id')
async findListing(@Param('id') listingId: string) {
return this.marketplaceService.findListingById(listingId);
}
@Get('instruments')
async listPublic(@Query() query: InstrumentQueryDto) {
return this.marketplaceService.getPublic(query);
return this.marketplaceService.getPublicInstruments(query);
}
@Get('instruments/:id')
async findOne(@Param('id') instrumentId: string) {
return this.marketplaceService.findById(instrumentId);
return this.marketplaceService.findInstrumentById(instrumentId);
}
@Get('repair-shops')
async listPublicRepairShops(@Query() query: RepairShopQueryDto) {
return this.marketplaceService.getPublicRepairShops(query);
}
@Get('repair-shops/:id')
async findRepairShop(@Param('id') repairShopId: string) {
return this.marketplaceService.findRepairShopById(repairShopId);
}
@Get('shops/:adminId')
async getShopByAdminId(@Param('adminId') adminId: string) {
return this.marketplaceService.getShopProfileByAdminId(adminId);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@Get('superadmin/listings')
async listAllListingsForSuperAdmin(@Query() query: InstrumentQueryDto) {
return this.marketplaceService.getListingsForSuperAdmin(query);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@Patch('superadmin/listings/:id/status')
async updateListingStatusBySuperAdmin(
@CurrentUser() user: JwtPayload,
@Param('id') listingId: string,
@Body() dto: UpdateMarketplaceStatusDto,
) {
return this.marketplaceService.updateListingStatusBySuperAdmin(
user.email ?? user.sub,
listingId,
dto,
);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@Delete('superadmin/listings/:id')
async deleteListingBySuperAdmin(@CurrentUser() user: JwtPayload, @Param('id') listingId: string) {
await this.marketplaceService.removeListingBySuperAdmin(user.email ?? user.sub, listingId);
return { success: true };
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@Get('superadmin/repair-shops')
async listAllRepairShopsForSuperAdmin(@Query() query: RepairShopQueryDto) {
return this.marketplaceService.getRepairShopsForSuperAdmin(query);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@Patch('superadmin/repair-shops/:id/status')
async updateRepairShopStatusBySuperAdmin(
@CurrentUser() user: JwtPayload,
@Param('id') repairShopId: string,
@Body() dto: UpdateMarketplaceStatusDto,
) {
return this.marketplaceService.updateRepairShopStatusBySuperAdmin(
user.email ?? user.sub,
repairShopId,
dto,
);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@Delete('superadmin/repair-shops/:id')
async deleteRepairShopBySuperAdmin(@CurrentUser() user: JwtPayload, @Param('id') repairShopId: string) {
await this.marketplaceService.removeRepairShopBySuperAdmin(user.email ?? user.sub, repairShopId);
return { success: true };
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
title: { type: 'string', example: 'Professional Oud' },
description: { type: 'string', example: 'Well-maintained oud for studio work' },
price: { type: 'number', example: 3500 },
currency: { type: 'string', example: 'SAR' },
quantity: { type: 'number', example: 1 },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
isActive: { type: 'boolean', example: true },
condition: { type: 'string', example: 'used' },
instrumentType: { type: 'string', example: 'Oud' },
listingCategory: { type: 'string', example: 'musical_instrument' },
},
required: ['title', 'price', 'quantity'],
},
})
@Post('superadmin/admins/:adminId/listings')
@Throttle(40, 60_000)
async createListingBySuperAdmin(
@Param('adminId') adminId: string,
@Body() dto: CreateInstrumentDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.createListingBySuperAdmin(adminId, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
title: { type: 'string', example: 'Concert Guitar' },
description: { type: 'string', example: 'Acoustic guitar in excellent condition' },
price: { type: 'number', example: 2100 },
currency: { type: 'string', example: 'SAR' },
quantity: { type: 'number', example: 1 },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
isActive: { type: 'boolean', example: true },
condition: { type: 'string', example: 'used' },
instrumentType: { type: 'string', example: 'Guitar' },
},
required: ['title', 'price', 'quantity'],
},
})
@Post('superadmin/admins/:adminId/instruments')
@Throttle(40, 60_000)
async createInstrumentBySuperAdmin(
@Param('adminId') adminId: string,
@Body() dto: CreateInstrumentDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.createInstrumentBySuperAdmin(adminId, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
name: { type: 'string', example: 'Fix Strings Workshop' },
description: { type: 'string', example: 'Repair shop for oud and violin' },
services: { type: 'array', items: { type: 'string' } },
phone: { type: 'string', example: '+966500000000' },
whatsapp: { type: 'string', example: '+966500000000' },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
location: { type: 'string', example: 'Riyadh' },
latitude: { type: 'number', example: 24.7136 },
longitude: { type: 'number', example: 46.6753 },
isActive: { type: 'boolean', example: true },
},
required: ['name'],
},
})
@Post('superadmin/admins/:adminId/repair-shops')
@Throttle(30, 60_000)
async createRepairShopBySuperAdmin(
@Param('adminId') adminId: string,
@Body() dto: CreateRepairShopDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.createRepairShopBySuperAdmin(adminId, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE)
@UseInterceptors(FileFieldsInterceptor([{ name: 'shopImageFiles', maxCount: 8 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
shopName: { type: 'string', example: 'Awtarna Store' },
shopDescription: { type: 'string', example: 'Trusted marketplace shop profile' },
shopImageUrls: { type: 'array', items: { type: 'string' } },
shopImageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
shopLocation: { type: 'string', example: 'Riyadh' },
shopLatitude: { type: 'number', example: 24.7136 },
shopLongitude: { type: 'number', example: 46.6753 },
},
},
})
@Patch('superadmin/admins/:adminId/shop-profile')
@Throttle(30, 60_000)
async updateShopProfileBySuperAdmin(
@Param('adminId') adminId: string,
@Body() dto: UpdateShopProfileDto,
@UploadedFiles()
files?: {
shopImageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.updateShopProfileBySuperAdmin(
adminId,
dto,
files?.shopImageFiles ?? [],
);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
title: { type: 'string', example: 'Professional Oud' },
description: { type: 'string', example: 'Well-maintained oud for studio work' },
price: { type: 'number', example: 3500 },
currency: { type: 'string', example: 'SAR' },
quantity: { type: 'number', example: 1 },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
isActive: { type: 'boolean', example: true },
condition: { type: 'string', example: 'used' },
instrumentType: { type: 'string', example: 'Oud' },
listingCategory: { type: 'string', example: 'musical_instrument' },
},
required: ['title', 'price', 'quantity'],
},
})
@Post('admin/listings')
@Throttle(40, 60_000)
async createListingByAdmin(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateInstrumentDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.createListingByAdmin(user.sub, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
title: { type: 'string', example: 'Updated listing title' },
description: { type: 'string', example: 'Updated description' },
price: { type: 'number', example: 3200 },
currency: { type: 'string', example: 'SAR' },
quantity: { type: 'number', example: 1 },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
isActive: { type: 'boolean', example: true },
instrumentType: { type: 'string', example: 'Oud' },
listingCategory: { type: 'string', example: 'musical_instrument' },
},
},
})
@Patch('admin/listings/:id')
@Throttle(60, 60_000)
async updateListingByAdmin(
@CurrentUser() user: JwtPayload,
@Param('id') listingId: string,
@Body() dto: UpdateInstrumentDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.updateListingByAdmin(user.sub, listingId, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Delete('admin/listings/:id')
@Throttle(40, 60_000)
async removeListingByAdmin(@CurrentUser() user: JwtPayload, @Param('id') listingId: string) {
await this.marketplaceService.removeListingByAdmin(user.sub, listingId);
return { success: true };
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('admin/listings/me')
async myListings(@CurrentUser() user: JwtPayload, @Query() query: InstrumentQueryDto) {
return this.marketplaceService.getMyListings(user.sub, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
title: { type: 'string', example: 'Concert Guitar' },
description: { type: 'string', example: 'Acoustic guitar in excellent condition' },
price: { type: 'number', example: 2100 },
currency: { type: 'string', example: 'SAR' },
quantity: { type: 'number', example: 1 },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
isActive: { type: 'boolean', example: true },
condition: { type: 'string', example: 'used' },
instrumentType: { type: 'string', example: 'Guitar' },
},
required: ['title', 'price', 'quantity'],
},
})
@Post('admin/instruments')
@Throttle(40, 60_000)
async createByAdmin(@CurrentUser() user: JwtPayload, @Body() dto: CreateInstrumentDto) {
return this.marketplaceService.createByAdmin(user.sub, dto);
async createByAdmin(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateInstrumentDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.createInstrumentByAdmin(user.sub, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 5 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
title: { type: 'string', example: 'Updated instrument title' },
description: { type: 'string', example: 'Updated instrument description' },
price: { type: 'number', example: 2400 },
quantity: { type: 'number', example: 1 },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
condition: { type: 'string', example: 'used' },
instrumentType: { type: 'string', example: 'Violin' },
},
},
})
@Patch('admin/instruments/:id')
@Throttle(60, 60_000)
async updateByAdmin(
@CurrentUser() user: JwtPayload,
@Param('id') instrumentId: string,
@Body() dto: UpdateInstrumentDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.updateByAdmin(user.sub, instrumentId, dto);
return this.marketplaceService.updateInstrumentByAdmin(
user.sub,
instrumentId,
dto,
files?.imageFiles ?? [],
);
}
@ApiBearerAuth()
@@ -55,7 +464,7 @@ export class MarketplaceController {
@Delete('admin/instruments/:id')
@Throttle(40, 60_000)
async removeByAdmin(@CurrentUser() user: JwtPayload, @Param('id') instrumentId: string) {
await this.marketplaceService.removeByAdmin(user.sub, instrumentId);
await this.marketplaceService.removeInstrumentByAdmin(user.sub, instrumentId);
return { success: true };
}
@@ -64,6 +473,147 @@ export class MarketplaceController {
@Roles(UserRole.ADMIN)
@Get('admin/instruments/me')
async myInstruments(@CurrentUser() user: JwtPayload, @Query() query: InstrumentQueryDto) {
return this.marketplaceService.getMine(user.sub, query);
return this.marketplaceService.getMyInstruments(user.sub, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
name: { type: 'string', example: 'Fix Strings Workshop' },
description: { type: 'string', example: 'Repair shop for oud and violin' },
services: { type: 'array', items: { type: 'string' } },
phone: { type: 'string', example: '+966500000000' },
whatsapp: { type: 'string', example: '+966500000000' },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
location: { type: 'string', example: 'Riyadh' },
latitude: { type: 'number', example: 24.7136 },
longitude: { type: 'number', example: 46.6753 },
isActive: { type: 'boolean', example: true },
},
required: ['name'],
},
})
@Post('admin/repair-shops')
@Throttle(30, 60_000)
async createRepairShop(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateRepairShopDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.createRepairShop(user.sub, dto, files?.imageFiles ?? []);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'imageFiles', maxCount: 8 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
name: { type: 'string', example: 'Updated shop name' },
description: { type: 'string', example: 'Updated repair shop description' },
services: { type: 'array', items: { type: 'string' } },
phone: { type: 'string', example: '+966500000000' },
whatsapp: { type: 'string', example: '+966500000000' },
imageUrls: { type: 'array', items: { type: 'string' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
location: { type: 'string', example: 'Jeddah' },
latitude: { type: 'number', example: 21.5433 },
longitude: { type: 'number', example: 39.1728 },
isActive: { type: 'boolean', example: true },
},
},
})
@Patch('admin/repair-shops/:id')
@Throttle(40, 60_000)
async updateRepairShop(
@CurrentUser() user: JwtPayload,
@Param('id') repairShopId: string,
@Body() dto: UpdateRepairShopDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.updateRepairShop(
user.sub,
repairShopId,
dto,
files?.imageFiles ?? [],
);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Delete('admin/repair-shops/:id')
@Throttle(30, 60_000)
async deleteRepairShop(@CurrentUser() user: JwtPayload, @Param('id') repairShopId: string) {
await this.marketplaceService.removeRepairShop(user.sub, repairShopId);
return { success: true };
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('admin/repair-shops/me')
async myRepairShops(@CurrentUser() user: JwtPayload, @Query() query: RepairShopQueryDto) {
return this.marketplaceService.getMyRepairShops(user.sub, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Get('admin/shop-profile/me')
async myShopProfile(@CurrentUser() user: JwtPayload) {
return this.marketplaceService.getMyShopProfile(user.sub);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(FileFieldsInterceptor([{ name: 'shopImageFiles', maxCount: 8 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
shopName: { type: 'string', example: 'Awtarna Store' },
shopDescription: { type: 'string', example: 'Trusted marketplace shop profile' },
shopImageUrls: { type: 'array', items: { type: 'string' } },
shopImageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
shopLocation: { type: 'string', example: 'Riyadh' },
shopLatitude: { type: 'number', example: 24.7136 },
shopLongitude: { type: 'number', example: 46.6753 },
},
},
})
@Patch('admin/shop-profile')
@Throttle(30, 60_000)
async updateShopProfile(
@CurrentUser() user: JwtPayload,
@Body() dto: UpdateShopProfileDto,
@UploadedFiles()
files?: {
shopImageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.marketplaceService.updateMyShopProfile(
user.sub,
dto,
files?.shopImageFiles ?? [],
);
}
}

عرض الملف

@@ -1,15 +1,23 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AuditModule } from '../audit/audit.module';
import { SuperAdminCase, SuperAdminCaseSchema } from '../superadmin/schemas/superadmin-case.schema';
import { UsersModule } from '../users/users.module';
import { MarketplaceController } from './marketplace.controller';
import { MarketplaceRepository } from './marketplace.repository';
import { MarketplaceService } from './marketplace.service';
import { Instrument, InstrumentSchema } from './schemas/instrument.schema';
import { RepairShop, RepairShopSchema } from './schemas/repair-shop.schema';
@Module({
imports: [
AuditModule,
UsersModule,
MongooseModule.forFeature([{ name: Instrument.name, schema: InstrumentSchema }]),
MongooseModule.forFeature([
{ name: Instrument.name, schema: InstrumentSchema },
{ name: RepairShop.name, schema: RepairShopSchema },
{ name: SuperAdminCase.name, schema: SuperAdminCaseSchema },
]),
],
controllers: [MarketplaceController],
providers: [MarketplaceService, MarketplaceRepository],

عرض الملف

@@ -2,12 +2,15 @@ import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
import { Instrument, InstrumentDocument } from './schemas/instrument.schema';
import { RepairShop, RepairShopDocument } from './schemas/repair-shop.schema';
@Injectable()
export class MarketplaceRepository {
constructor(
@InjectModel(Instrument.name)
private readonly instrumentModel: Model<InstrumentDocument>,
@InjectModel(RepairShop.name)
private readonly repairShopModel: Model<RepairShopDocument>,
) {}
async create(ownerAdminId: string, payload: Partial<Instrument>): Promise<InstrumentDocument> {
@@ -23,7 +26,7 @@ export class MarketplaceRepository {
}
return this.instrumentModel
.findById(instrumentId)
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' })
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.exec();
}
@@ -37,7 +40,7 @@ export class MarketplaceRepository {
return this.instrumentModel
.findByIdAndUpdate(instrumentId, payload, { new: true })
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' })
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.exec();
}
@@ -52,11 +55,12 @@ export class MarketplaceRepository {
filter: FilterQuery<InstrumentDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<InstrumentDocument[]> {
return this.instrumentModel
.find(filter)
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled' })
.sort({ createdAt: -1 })
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
@@ -66,6 +70,7 @@ export class MarketplaceRepository {
filter: FilterQuery<InstrumentDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<Record<string, unknown>[]> {
return this.instrumentModel
.aggregate([
@@ -80,7 +85,7 @@ export class MarketplaceRepository {
},
{ $unwind: '$ownerAdmin' },
{ $match: { 'ownerAdmin.isDisabled': false } },
{ $sort: { createdAt: -1 } },
{ $sort: sort },
{ $skip: skip },
{ $limit: limit },
{
@@ -93,6 +98,9 @@ export class MarketplaceRepository {
quantity: 1,
imageUrls: 1,
isActive: 1,
condition: 1,
instrumentType: 1,
listingCategory: 1,
createdAt: 1,
updatedAt: 1,
ownerAdminId: {
@@ -101,6 +109,7 @@ export class MarketplaceRepository {
username: '$ownerAdmin.username',
email: '$ownerAdmin.email',
avatar: '$ownerAdmin.avatar',
shopName: '$ownerAdmin.shopName',
},
},
},
@@ -132,4 +141,147 @@ export class MarketplaceRepository {
async count(filter: FilterQuery<InstrumentDocument>): Promise<number> {
return this.instrumentModel.countDocuments(filter).exec();
}
async createRepairShop(
ownerAdminId: string,
payload: Partial<RepairShop>,
): Promise<RepairShopDocument> {
return this.repairShopModel.create({
...payload,
ownerAdminId: new Types.ObjectId(ownerAdminId),
});
}
async findRepairShopByOwnerAdminId(ownerAdminId: string): Promise<RepairShopDocument | null> {
if (!Types.ObjectId.isValid(ownerAdminId)) {
return null;
}
return this.repairShopModel
.findOne({ ownerAdminId: new Types.ObjectId(ownerAdminId) })
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.exec();
}
async findRepairShopById(repairShopId: string): Promise<RepairShopDocument | null> {
if (!Types.ObjectId.isValid(repairShopId)) {
return null;
}
return this.repairShopModel
.findById(repairShopId)
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.exec();
}
async updateRepairShopById(
repairShopId: string,
payload: UpdateQuery<RepairShopDocument>,
): Promise<RepairShopDocument | null> {
if (!Types.ObjectId.isValid(repairShopId)) {
return null;
}
return this.repairShopModel
.findByIdAndUpdate(repairShopId, payload, { new: true })
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.exec();
}
async deleteRepairShopById(repairShopId: string): Promise<RepairShopDocument | null> {
if (!Types.ObjectId.isValid(repairShopId)) {
return null;
}
return this.repairShopModel.findByIdAndDelete(repairShopId).exec();
}
async findManyRepairShops(
filter: FilterQuery<RepairShopDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<RepairShopDocument[]> {
return this.repairShopModel
.find(filter)
.populate({ path: 'ownerAdminId', select: 'name username email avatar isDisabled shopName' })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
}
async findManyRepairShopsPublic(
filter: FilterQuery<RepairShopDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<Record<string, unknown>[]> {
return this.repairShopModel
.aggregate([
{ $match: filter },
{
$lookup: {
from: 'users',
localField: 'ownerAdminId',
foreignField: '_id',
as: 'ownerAdmin',
},
},
{ $unwind: '$ownerAdmin' },
{ $match: { 'ownerAdmin.isDisabled': false } },
{ $sort: sort },
{ $skip: skip },
{ $limit: limit },
{
$project: {
_id: 1,
name: 1,
description: 1,
services: 1,
phone: 1,
whatsapp: 1,
imageUrls: 1,
location: 1,
latitude: 1,
longitude: 1,
isActive: 1,
createdAt: 1,
updatedAt: 1,
ownerAdminId: {
_id: '$ownerAdmin._id',
name: '$ownerAdmin.name',
username: '$ownerAdmin.username',
email: '$ownerAdmin.email',
avatar: '$ownerAdmin.avatar',
shopName: '$ownerAdmin.shopName',
},
},
},
])
.exec();
}
async countRepairShopsPublic(filter: FilterQuery<RepairShopDocument>): Promise<number> {
const rows = await this.repairShopModel
.aggregate([
{ $match: filter },
{
$lookup: {
from: 'users',
localField: 'ownerAdminId',
foreignField: '_id',
as: 'ownerAdmin',
},
},
{ $unwind: '$ownerAdmin' },
{ $match: { 'ownerAdmin.isDisabled': false } },
{ $count: 'count' },
])
.exec();
return rows[0]?.count ?? 0;
}
async countRepairShops(filter: FilterQuery<RepairShopDocument>): Promise<number> {
return this.repairShopModel.countDocuments(filter).exec();
}
}

تم حذف اختلاف الملف لأن الملف كبير جداً تحميل الاختلاف

عرض الملف

@@ -1,6 +1,9 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { resolveManagedFileUrls } from '../../../common/utils/public-url.util';
import { User } from '../../users/schemas/user.schema';
import { MarketplaceListingCategory } from '../enums/marketplace-listing-category.enum';
import { MarketplaceListingCondition } from '../enums/marketplace-listing-condition.enum';
export type InstrumentDocument = HydratedDocument<Instrument>;
@@ -29,9 +32,39 @@ export class Instrument {
@Prop({ default: true, index: true })
isActive!: boolean;
@Prop({
type: String,
enum: MarketplaceListingCondition,
default: MarketplaceListingCondition.USED,
index: true,
})
condition!: MarketplaceListingCondition;
@Prop({ default: '', trim: true, maxlength: 80, index: true })
instrumentType!: string;
@Prop({
type: String,
enum: MarketplaceListingCategory,
default: MarketplaceListingCategory.MUSICAL_INSTRUMENT,
index: true,
})
listingCategory!: MarketplaceListingCategory;
}
export const InstrumentSchema = SchemaFactory.createForClass(Instrument);
InstrumentSchema.index({ ownerAdminId: 1, createdAt: -1 });
InstrumentSchema.index({ isActive: 1, createdAt: -1 });
InstrumentSchema.index({ title: 1, isActive: 1, createdAt: -1 });
InstrumentSchema.index({ listingCategory: 1, isActive: 1, createdAt: -1 });
InstrumentSchema.index({ condition: 1, isActive: 1, createdAt: -1 });
InstrumentSchema.index({ instrumentType: 1, isActive: 1, createdAt: -1 });
const transformManagedInstrumentFiles = (_doc: unknown, ret: any) => {
ret.imageUrls = resolveManagedFileUrls(ret.imageUrls);
return ret;
};
InstrumentSchema.set('toJSON', { transform: transformManagedInstrumentFiles });
InstrumentSchema.set('toObject', { transform: transformManagedInstrumentFiles });

عرض الملف

@@ -0,0 +1,55 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { resolveManagedFileUrls } from '../../../common/utils/public-url.util';
import { User } from '../../users/schemas/user.schema';
export type RepairShopDocument = HydratedDocument<RepairShop>;
@Schema({ timestamps: true, versionKey: false })
export class RepairShop {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
ownerAdminId!: Types.ObjectId;
@Prop({ required: true, trim: true, maxlength: 120, index: true })
name!: string;
@Prop({ default: '', trim: true, maxlength: 2000 })
description!: string;
@Prop({ type: [String], default: [] })
services!: string[];
@Prop({ default: '', trim: true, maxlength: 40 })
phone!: string;
@Prop({ default: '', trim: true, maxlength: 40 })
whatsapp!: string;
@Prop({ type: [String], default: [] })
imageUrls!: string[];
@Prop({ default: '', trim: true, maxlength: 160 })
location!: string;
@Prop({ type: Number, min: -90, max: 90, default: null })
latitude!: number | null;
@Prop({ type: Number, min: -180, max: 180, default: null })
longitude!: number | null;
@Prop({ default: true, index: true })
isActive!: boolean;
}
export const RepairShopSchema = SchemaFactory.createForClass(RepairShop);
RepairShopSchema.index({ ownerAdminId: 1, createdAt: -1 });
RepairShopSchema.index({ isActive: 1, createdAt: -1 });
RepairShopSchema.index({ name: 1, isActive: 1, createdAt: -1 });
const transformManagedRepairShopFiles = (_doc: unknown, ret: any) => {
ret.imageUrls = resolveManagedFileUrls(ret.imageUrls);
return ret;
};
RepairShopSchema.set('toJSON', { transform: transformManagedRepairShopFiles });
RepairShopSchema.set('toObject', { transform: transformManagedRepairShopFiles });

عرض الملف

@@ -0,0 +1,23 @@
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, Max, Min } from 'class-validator';
export class TextToMusicDto {
@IsString()
@IsNotEmpty()
@MaxLength(500)
prompt!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(5)
@Max(30)
durationSeconds?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(2147483647)
seed?: number;
}

عرض الملف

@@ -1,6 +1,22 @@
import { Controller } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '../../common/decorators/throttle.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { TextToMusicDto } from './dto/text-to-music.dto';
import { MediaService } from './media.service';
@ApiTags('Media')
@Controller('media')
export class MediaController {}
export class MediaController {
constructor(private readonly mediaService: MediaService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post('ai/text-to-music')
@Throttle(6, 60_000)
async generateMusicFromText(@CurrentUser() user: JwtPayload, @Body() dto: TextToMusicDto) {
return this.mediaService.generateMusicFromText(user.sub, dto);
}
}

عرض الملف

@@ -1,4 +1,133 @@
import { Injectable } from '@nestjs/common';
import {
BadGatewayException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleAuth } from 'google-auth-library';
import { generateWaveformPeaksFromBuffer } from '../../common/utils/waveform.util';
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
import { TextToMusicDto } from './dto/text-to-music.dto';
@Injectable()
export class MediaService {}
export class MediaService {
constructor(
private readonly configService: ConfigService,
private readonly storageService: ManagedStorageService,
) {}
async generateMusicFromText(userId: string, dto: TextToMusicDto) {
const enabled = this.configService.get<boolean>('aiMusic.enabled', { infer: true });
if (!enabled) {
throw new ServiceUnavailableException('AI music generation is disabled');
}
const apiKey = this.configService.get<string>('aiMusic.apiKey', { infer: true }) ?? '';
const projectId = this.configService.get<string>('aiMusic.projectId', { infer: true }) ?? '';
const location = this.configService.get<string>('aiMusic.location', { infer: true }) ?? '';
const model = this.configService.get<string>('aiMusic.model', { infer: true }) ?? 'lyria-002';
if (!projectId || !location) {
throw new ServiceUnavailableException('AI music settings are not configured');
}
let url = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${model}:predict`;
let authorizationHeader: string | null = null;
if (apiKey) {
url = `${url}?key=${encodeURIComponent(apiKey)}`;
} else {
const auth = new GoogleAuth({
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
});
const client = await auth.getClient();
const accessTokenRaw = await client.getAccessToken();
const accessToken =
typeof accessTokenRaw === 'string' ? accessTokenRaw : accessTokenRaw?.token ?? '';
if (!accessToken) {
throw new ServiceUnavailableException('Failed to authenticate with Google Cloud');
}
authorizationHeader = `Bearer ${accessToken}`;
}
const requestBody: Record<string, unknown> = {
instances: [{ prompt: dto.prompt }],
parameters: {
sampleCount: 1,
durationSeconds: dto.durationSeconds ?? 12,
},
};
if (typeof dto.seed === 'number') {
(requestBody.parameters as Record<string, unknown>).seed = dto.seed;
}
const response = await fetch(url, {
method: 'POST',
headers: {
...(authorizationHeader ? { Authorization: authorizationHeader } : {}),
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
const result = (await response.json()) as {
error?: { message?: string };
predictions?: Array<{
bytesBase64Encoded?: string;
mimeType?: string;
audio?: { bytesBase64Encoded?: string; mimeType?: string };
}>;
};
if (!response.ok) {
throw new BadGatewayException(
result?.error?.message ?? 'Google AI returned an unexpected error',
);
}
const first = result?.predictions?.[0];
const audioBase64 = first?.bytesBase64Encoded ?? first?.audio?.bytesBase64Encoded ?? '';
const mimeType = first?.mimeType ?? first?.audio?.mimeType ?? 'audio/wav';
if (!audioBase64) {
throw new BadGatewayException('Google AI did not return audio content');
}
const extension = this.resolveAudioExtension(mimeType);
const buffer = Buffer.from(audioBase64, 'base64');
const audioUrl = await this.storageService.saveFile({
folderSegments: ['ai-music'],
extension: `.${extension}`,
buffer,
contentType: mimeType,
fileNamePrefix: `ai-${userId}`,
});
return {
prompt: dto.prompt,
durationSeconds: dto.durationSeconds ?? 12,
mimeType,
sizeBytes: buffer.length,
audioUrl,
waveformPeaks: generateWaveformPeaksFromBuffer(buffer),
};
}
private resolveAudioExtension(mimeType: string): string {
if (mimeType.includes('mpeg') || mimeType.includes('mp3')) {
return 'mp3';
}
if (mimeType.includes('ogg')) {
return 'ogg';
}
if (mimeType.includes('aac')) {
return 'aac';
}
if (mimeType.includes('wav')) {
return 'wav';
}
return 'wav';
}
}

عرض الملف

@@ -1,4 +1,5 @@
import { IsEnum, IsMongoId, IsOptional } from 'class-validator';
import { IsEnum, IsMongoId, IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
import { NOTIFICATION_TYPES, NotificationType } from '../schemas/notification.schema';
export class CreateNotificationDto {
@IsMongoId()
@@ -7,10 +8,34 @@ export class CreateNotificationDto {
@IsMongoId()
actorId!: string;
@IsEnum(['like', 'comment', 'follow', 'message'])
type!: 'like' | 'comment' | 'follow' | 'message';
@IsEnum(NOTIFICATION_TYPES)
type!: NotificationType;
@IsOptional()
@IsMongoId()
referenceId?: string;
@IsOptional()
@IsString()
@MaxLength(120)
title?: string;
@IsOptional()
@IsString()
@MaxLength(500)
previewText?: string;
@IsOptional()
@IsString()
@MaxLength(80)
resourceType?: string;
@IsOptional()
@IsString()
@MaxLength(240)
deepLink?: string;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

عرض الملف

@@ -1,14 +1,24 @@
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator';
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
import { toBoolean } from '../../../common/utils/query-transform.util';
import { NOTIFICATION_TYPES, NotificationType } from '../schemas/notification.schema';
export class NotificationQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ default: false })
@IsOptional()
@Transform(({ value }) => {
if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false;
return value;
})
@Transform(toBoolean)
@IsBoolean()
read?: boolean;
@ApiPropertyOptional({ enum: NOTIFICATION_TYPES })
@IsOptional()
@IsEnum(NOTIFICATION_TYPES)
type?: NotificationType;
@ApiPropertyOptional({ example: 'comment' })
@IsOptional()
@IsString()
resourceType?: string;
}

عرض الملف

@@ -1,33 +1,48 @@
import { Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
import { NotificationQueryDto } from './dto/notification-query.dto';
import { NotificationsService } from './notifications.service';
@ApiTags('Notifications')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.NOTIFICATIONS_READ)
@Get('superadmin')
async getForSuperAdmin(@Query() query: NotificationQueryDto) {
return this.notificationsService.getForSuperAdmin(query);
}
@UseGuards(JwtAuthGuard)
@Get()
async getMine(@CurrentUser() user: JwtPayload, @Query() query: NotificationQueryDto) {
return this.notificationsService.getMine(user.sub, query);
}
@UseGuards(JwtAuthGuard)
@Get('unread-count')
async getUnreadCount(@CurrentUser() user: JwtPayload) {
return this.notificationsService.getUnreadCount(user.sub);
}
@UseGuards(JwtAuthGuard)
@Patch('read-all')
async markAllRead(@CurrentUser() user: JwtPayload) {
return this.notificationsService.markAllRead(user.sub);
}
@UseGuards(JwtAuthGuard)
@Patch(':id/read')
async markRead(@CurrentUser() user: JwtPayload, @Param('id') notificationId: string) {
return this.notificationsService.markRead(user.sub, notificationId);

عرض الملف

@@ -31,6 +31,7 @@ export class NotificationsRepository {
filter: FilterQuery<NotificationDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<NotificationDocument[]> {
return this.notificationModel
.find({
@@ -38,7 +39,22 @@ export class NotificationsRepository {
...filter,
})
.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' })
.sort({ createdAt: -1 })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
}
async findMany(
filter: FilterQuery<NotificationDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<NotificationDocument[]> {
return this.notificationModel
.find(filter)
.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
@@ -62,6 +78,19 @@ export class NotificationsRepository {
.exec();
}
async count(filter: FilterQuery<NotificationDocument>): Promise<number> {
return this.notificationModel.countDocuments(filter).exec();
}
async countUnreadAll(filter: FilterQuery<NotificationDocument> = {}): Promise<number> {
return this.notificationModel
.countDocuments({
...filter,
read: false,
})
.exec();
}
async markRead(recipientId: string, notificationId: string): Promise<NotificationDocument | null> {
const updated = await this.notificationModel
.findOneAndUpdate(

عرض الملف

@@ -2,6 +2,35 @@ import { NotFoundException } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
describe('NotificationsService', () => {
it('creates mention notifications with mention type', async () => {
const notificationsRepository = {
create: jest.fn().mockResolvedValue({ toJSON: () => ({ _id: 'notification-1' }) }),
countUnread: jest.fn().mockResolvedValue(5),
};
const notificationsGateway = {
emitCreated: jest.fn(),
};
const service = new NotificationsService(
notificationsRepository as any,
notificationsGateway as any,
);
await service.createMentionNotification(
'507f1f77bcf86cd799439011',
'507f191e810c19729de860ea',
'507f1f77bcf86cd799439012',
{ previewText: 'Hello @rami' },
);
expect(notificationsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
type: 'mention',
previewText: 'Hello @rami',
}),
);
});
it('recalculates unread count after markAllRead', async () => {
const notificationsRepository = {
markAllRead: jest.fn().mockResolvedValue(4),

عرض الملف

@@ -1,9 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { CreateNotificationDto } from './dto/create-notification.dto';
import { NotificationQueryDto } from './dto/notification-query.dto';
import { NotificationsGateway } from './notifications.gateway';
import { NotificationsRepository } from './notifications.repository';
import { NotificationType } from './schemas/notification.schema';
@Injectable()
export class NotificationsService {
@@ -17,11 +20,20 @@ export class NotificationsService {
return null;
}
const resourceType = (dto.resourceType ?? this.resolveResourceType(dto.type)).trim();
const deepLink = (dto.deepLink ?? this.buildDeepLink(dto.type, dto.referenceId, resourceType)).trim();
const title = (dto.title ?? this.buildTitle(dto.type)).trim();
const previewText = (dto.previewText ?? '').trim();
const notification = await this.notificationsRepository.create({
recipientId: new Types.ObjectId(dto.recipientId),
actorId: new Types.ObjectId(dto.actorId),
type: dto.type,
referenceId: dto.referenceId ? new Types.ObjectId(dto.referenceId) : undefined,
title,
previewText,
resourceType,
deepLink,
metadata: dto.metadata ?? {},
read: false,
readAt: null,
});
@@ -37,7 +49,107 @@ export class NotificationsService {
actorId,
recipientId,
type: 'follow',
referenceId: referenceId || actorId,
resourceType: 'user',
deepLink: `/users/${actorId}`,
});
}
async createLikeNotification(
actorId: string,
recipientId: string,
referenceId: string,
options?: { resourceType?: string; previewText?: string },
) {
return this.create({
actorId,
recipientId,
type: 'like',
referenceId,
resourceType: options?.resourceType ?? 'post',
previewText: options?.previewText ?? '',
});
}
async createCommentNotification(
actorId: string,
recipientId: string,
referenceId: string,
options?: { resourceType?: string; previewText?: string },
) {
return this.create({
actorId,
recipientId,
type: 'comment',
referenceId,
resourceType: options?.resourceType ?? 'post',
previewText: options?.previewText ?? '',
});
}
async createSaveNotification(
actorId: string,
recipientId: string,
referenceId: string,
options?: { resourceType?: string; previewText?: string },
) {
return this.create({
actorId,
recipientId,
type: 'save',
referenceId,
resourceType: options?.resourceType ?? 'post',
previewText: options?.previewText ?? '',
});
}
async createShareNotification(
actorId: string,
recipientId: string,
referenceId: string,
options?: { resourceType?: string; previewText?: string },
) {
return this.create({
actorId,
recipientId,
type: 'share',
referenceId,
resourceType: options?.resourceType ?? 'post',
previewText: options?.previewText ?? '',
});
}
async createMentionNotification(
actorId: string,
recipientId: string,
referenceId: string,
options?: { resourceType?: string; previewText?: string; deepLink?: string },
) {
return this.create({
actorId,
recipientId,
type: 'mention',
referenceId,
resourceType: options?.resourceType ?? 'post',
previewText: options?.previewText ?? '',
deepLink: options?.deepLink,
});
}
async createMessageNotification(
actorId: string,
recipientId: string,
conversationId: string,
previewText = '',
) {
return this.create({
actorId,
recipientId,
type: 'message',
referenceId: conversationId,
resourceType: 'conversation',
deepLink: `/chat/conversations/${conversationId}`,
previewText,
});
}
@@ -50,20 +162,64 @@ export class NotificationsService {
if (typeof query.read === 'boolean') {
filter.read = query.read;
}
if (query.type) {
filter.type = query.type;
}
if (query.resourceType) {
filter.resourceType = query.resourceType.trim();
}
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total, unreadCount] = await Promise.all([
this.notificationsRepository.findMine(recipientId, filter, skip, limit),
this.notificationsRepository.findMine(recipientId, filter, skip, limit, sort),
this.notificationsRepository.countMine(recipientId, filter),
this.notificationsRepository.countUnread(recipientId),
]);
return {
items,
...buildPaginatedResponse(items, {
page,
limit,
total,
offset: skip,
}),
unreadCount,
};
}
async getForSuperAdmin(query: NotificationQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const filter: Record<string, unknown> = {};
if (typeof query.read === 'boolean') {
filter.read = query.read;
}
if (query.type) {
filter.type = query.type;
}
if (query.resourceType) {
filter.resourceType = query.resourceType.trim();
}
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total, unreadCount] = await Promise.all([
this.notificationsRepository.findMany(filter, skip, limit, sort),
this.notificationsRepository.count(filter),
this.notificationsRepository.countUnreadAll(filter),
]);
return {
...buildPaginatedResponse(items, {
page,
limit,
total,
offset: skip,
}),
unreadCount,
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
}
@@ -104,4 +260,60 @@ export class NotificationsService {
unreadCount,
};
}
private buildTitle(type: NotificationType): string {
switch (type) {
case 'like':
return 'New like';
case 'comment':
return 'New comment';
case 'follow':
return 'New follower';
case 'message':
return 'New message';
case 'save':
return 'Post saved';
case 'share':
return 'Post shared';
case 'mention':
return 'New mention';
default:
return 'Notification';
}
}
private resolveResourceType(type: NotificationType): string {
switch (type) {
case 'follow':
return 'user';
case 'message':
return 'conversation';
default:
return 'post';
}
}
private buildDeepLink(
type: NotificationType,
referenceId?: string,
resourceType?: string,
): string {
if (type === 'message' && referenceId) {
return `/chat/conversations/${referenceId}`;
}
if (resourceType === 'user' && referenceId) {
return `/users/${referenceId}`;
}
if (resourceType === 'comment' && referenceId) {
return `/comments/${referenceId}`;
}
if (referenceId) {
return `/posts/${referenceId}`;
}
return '';
}
}

عرض الملف

@@ -4,6 +4,9 @@ import { User } from '../../users/schemas/user.schema';
export type NotificationDocument = HydratedDocument<Notification>;
export const NOTIFICATION_TYPES = ['like', 'comment', 'follow', 'message', 'save', 'share', 'mention'] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
@Schema({ timestamps: true, versionKey: false })
export class Notification {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
@@ -12,12 +15,27 @@ export class Notification {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
actorId!: Types.ObjectId;
@Prop({ required: true, enum: ['like', 'comment', 'follow', 'message'] })
type!: 'like' | 'comment' | 'follow' | 'message';
@Prop({ required: true, enum: NOTIFICATION_TYPES })
type!: NotificationType;
@Prop({ type: Types.ObjectId })
referenceId?: Types.ObjectId;
@Prop({ default: '', trim: true, maxlength: 120 })
title!: string;
@Prop({ default: '', trim: true, maxlength: 500 })
previewText!: string;
@Prop({ default: '', trim: true, maxlength: 80, index: true })
resourceType!: string;
@Prop({ default: '', trim: true, maxlength: 240 })
deepLink!: string;
@Prop({ type: Object, default: {} })
metadata!: Record<string, unknown>;
@Prop({ default: false })
read!: boolean;
@@ -29,3 +47,4 @@ export const NotificationSchema = SchemaFactory.createForClass(Notification);
NotificationSchema.index({ recipientId: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, read: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, read: 1, type: 1, createdAt: -1 });
NotificationSchema.index({ recipientId: 1, resourceType: 1, createdAt: -1 });

عرض الملف

@@ -1,18 +1,28 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import { AppQueueService } from '../../infrastructure/queue/app-queue.service';
import { NotificationsService } from '../notifications/notifications.service';
import { OutboxEvent, OutboxEventDocument } from './schemas/outbox-event.schema';
@Injectable()
export class OutboxService {
private readonly logger = new Logger(OutboxService.name);
export class OutboxService implements OnModuleInit {
private static readonly PROCESS_EVENT_JOB = 'process_outbox_event';
constructor(
@InjectModel(OutboxEvent.name) private readonly outboxEventModel: Model<OutboxEventDocument>,
private readonly notificationsService: NotificationsService,
private readonly queueService: AppQueueService,
private readonly logger: AppLoggerService,
) {}
onModuleInit(): void {
this.queueService.registerProcessor(OutboxService.PROCESS_EVENT_JOB, async (payload) => {
await this.processEvent(String(payload.eventId ?? ''));
});
}
async enqueueFollowNotification(actorId: string, recipientId: string, referenceId?: string): Promise<void> {
const event = await this.outboxEventModel.create({
eventType: 'follow_notification',
@@ -24,7 +34,7 @@ export class OutboxService {
status: 'pending',
});
await this.processEvent(event.id);
await this.queueService.enqueue(OutboxService.PROCESS_EVENT_JOB, { eventId: event.id });
}
async processEvent(eventId: string): Promise<void> {
@@ -48,7 +58,14 @@ export class OutboxService {
} catch (error) {
event.status = 'failed';
event.lastError = error instanceof Error ? error.message : 'unknown outbox error';
this.logger.warn(`Outbox event ${event.id} failed: ${event.lastError}`);
this.logger.warn(
{
eventId: event.id,
eventType: event.eventType,
error: event.lastError,
},
OutboxService.name,
);
} finally {
event.attempts += 1;
await event.save();

عرض الملف

@@ -0,0 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsMongoId, IsOptional } from 'class-validator';
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
import { PostQueryDto } from './post-query.dto';
export class AdminPostQueryDto extends PostQueryDto {
@ApiPropertyOptional({ description: 'Optional author filter' })
@IsOptional()
@IsMongoId()
authorId?: string;
@ApiPropertyOptional({ enum: ModerationStatus, description: 'Optional moderation status filter' })
@IsOptional()
@IsEnum(ModerationStatus)
moderationStatus?: ModerationStatus;
}

عرض الملف

@@ -1,12 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator';
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsEnum,
IsMongoId,
IsNumber,
IsOptional,
IsString,
IsUrl,
Length,
Max,
Min,
} from 'class-validator';
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
import { toNumberArray, toStringArray } from '../../../common/utils/array-transform.util';
export class CreatePostDto {
@ApiProperty({ maxLength: 2200, description: 'Post description/content' })
@ApiPropertyOptional({ maxLength: 2200, description: 'Post caption/content (optional with media)' })
@IsOptional()
@IsString()
@Length(1, 2200)
content!: string;
@Length(0, 2200)
content?: string;
@ApiPropertyOptional({ description: 'Single video URL (optional)' })
@IsOptional()
@@ -18,6 +33,92 @@ export class CreatePostDto {
@IsUrl({ require_tld: false })
audioUrl?: string;
@ApiPropertyOptional({ description: 'Media duration in seconds for audio/video posts' })
@IsOptional()
@IsNumber()
@Min(1)
@Max(7200)
durationSeconds?: number;
@ApiPropertyOptional({ description: 'Cover/thumbnail URL for audio/video posts' })
@IsOptional()
@IsUrl({ require_tld: false })
thumbnailUrl?: string;
@ApiPropertyOptional({ description: 'Music style or genre for audio/video posts', maxLength: 80 })
@IsOptional()
@IsString()
@Length(0, 80)
style?: string;
@ApiPropertyOptional({ description: 'Maqam name for audio/video posts', maxLength: 80 })
@IsOptional()
@IsString()
@Length(0, 80)
maqam?: string;
@ApiPropertyOptional({ description: 'Rhythm signature like 6/8 for audio/video posts', maxLength: 40 })
@IsOptional()
@IsString()
@Length(0, 40)
rhythmSignature?: string;
@ApiPropertyOptional({
type: [Number],
description: 'Optional waveform samples for audio posts only',
})
@IsOptional()
@Transform(toNumberArray)
@IsArray()
@ArrayMaxSize(512)
@IsNumber({}, { each: true })
waveformPeaks?: number[];
@ApiPropertyOptional({ type: [String], description: 'Multiple image URLs for image carousel (max 10)' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(10)
@IsUrl({ require_tld: false }, { each: true })
imageUrls?: string[];
@ApiPropertyOptional({ type: [String], description: 'Tagged user ids (max 20)' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(20)
@IsMongoId({ each: true })
taggedUserIds?: string[];
@ApiPropertyOptional({ type: [String], description: 'Mention usernames like rami_sabry (max 30)' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(30)
@IsString({ each: true })
@Length(1, 30, { each: true })
mentionUsernames?: string[];
@ApiPropertyOptional({ description: 'Post location text' })
@IsOptional()
@IsString()
@Length(0, 120)
location?: string;
@ApiPropertyOptional({ description: 'Post latitude' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number;
@ApiPropertyOptional({ description: 'Post longitude' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number;
@ApiPropertyOptional({ enum: PostVisibility, default: PostVisibility.PUBLIC })
@IsOptional()
@IsEnum(PostVisibility)

عرض الملف

@@ -0,0 +1,73 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUrl,
Length,
Max,
Min,
} from 'class-validator';
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
import { toStringArray } from '../../../common/utils/array-transform.util';
export class CreateReelDto {
@ApiPropertyOptional({ maxLength: 2200, description: 'Reel caption' })
@IsOptional()
@IsString()
@Length(0, 2200)
content?: string;
@ApiPropertyOptional({ description: 'Reel video URL (if not uploading videoFile)' })
@IsOptional()
@IsUrl({ require_tld: false })
videoUrl?: string;
@ApiPropertyOptional({ description: 'Video duration in seconds' })
@IsOptional()
@IsNumber()
@Min(1)
@Max(7200)
durationSeconds?: number;
@ApiPropertyOptional({ description: 'Video thumbnail URL' })
@IsOptional()
@IsUrl({ require_tld: false })
thumbnailUrl?: string;
@ApiPropertyOptional({ description: 'Music style or genre for the reel', maxLength: 80 })
@IsOptional()
@IsString()
@Length(0, 80)
style?: string;
@ApiPropertyOptional({ description: 'Maqam name for the reel', maxLength: 80 })
@IsOptional()
@IsString()
@Length(0, 80)
maqam?: string;
@ApiPropertyOptional({ description: 'Rhythm signature like 6/8 for the reel', maxLength: 40 })
@IsOptional()
@IsString()
@Length(0, 40)
rhythmSignature?: string;
@ApiPropertyOptional({ type: [String], description: 'Mention usernames like rami_sabry (max 30)' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(30)
@IsString({ each: true })
@Length(1, 30, { each: true })
mentionUsernames?: string[];
@ApiPropertyOptional({ enum: PostVisibility, default: PostVisibility.PUBLIC })
@IsOptional()
@IsEnum(PostVisibility)
visibility?: PostVisibility;
}

عرض الملف

@@ -1,11 +1,45 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { PostType } from '../../../common/enums/post-type.enum';
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
export const POST_SORT_FIELDS = [
'createdAt',
'updatedAt',
'likesCount',
'commentsCount',
'savesCount',
'shareCount',
'viewCount',
'playCount',
] as const;
export type PostSortField = (typeof POST_SORT_FIELDS)[number];
export class PostQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: PostVisibility })
@IsOptional()
@IsEnum(PostVisibility)
visibility?: PostVisibility;
@ApiPropertyOptional({ enum: PostType })
@IsOptional()
@IsEnum(PostType)
postType?: PostType;
@ApiPropertyOptional({ description: 'Search inside post content' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ description: 'Filter by hashtag without the # prefix', example: 'music' })
@IsOptional()
@IsString()
hashtag?: string;
@ApiPropertyOptional({ enum: POST_SORT_FIELDS, default: 'createdAt' })
@IsOptional()
@IsEnum(POST_SORT_FIELDS)
sortBy?: PostSortField;
}

عرض الملف

@@ -0,0 +1,27 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsMongoId, IsOptional, IsString } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
import { POST_SORT_FIELDS, PostSortField } from './post-query.dto';
export class ReelQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: PostVisibility })
@IsOptional()
@IsEnum(PostVisibility)
visibility?: PostVisibility;
@ApiPropertyOptional({ description: 'Optional author filter' })
@IsOptional()
@IsMongoId()
authorId?: string;
@ApiPropertyOptional({ description: 'Search inside reel caption/content' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ enum: POST_SORT_FIELDS, default: 'createdAt' })
@IsOptional()
@IsEnum(POST_SORT_FIELDS)
sortBy?: PostSortField;
}

عرض الملف

@@ -1,6 +1,20 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator';
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsEnum,
IsMongoId,
IsNumber,
IsOptional,
IsString,
IsUrl,
Length,
Max,
Min,
} from 'class-validator';
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
import { toNumberArray, toStringArray } from '../../../common/utils/array-transform.util';
export class UpdatePostDto {
@ApiPropertyOptional({ maxLength: 2200 })
@@ -19,6 +33,92 @@ export class UpdatePostDto {
@IsUrl({ require_tld: false })
audioUrl?: string;
@ApiPropertyOptional({ description: 'Media duration in seconds for audio/video posts' })
@IsOptional()
@IsNumber()
@Min(1)
@Max(7200)
durationSeconds?: number;
@ApiPropertyOptional({ description: 'Cover/thumbnail URL for audio/video posts' })
@IsOptional()
@IsUrl({ require_tld: false })
thumbnailUrl?: string;
@ApiPropertyOptional({ description: 'Music style or genre for audio/video posts', maxLength: 80 })
@IsOptional()
@IsString()
@Length(0, 80)
style?: string;
@ApiPropertyOptional({ description: 'Maqam name for audio/video posts', maxLength: 80 })
@IsOptional()
@IsString()
@Length(0, 80)
maqam?: string;
@ApiPropertyOptional({ description: 'Rhythm signature like 6/8 for audio/video posts', maxLength: 40 })
@IsOptional()
@IsString()
@Length(0, 40)
rhythmSignature?: string;
@ApiPropertyOptional({
type: [Number],
description: 'Optional waveform samples for audio posts only',
})
@IsOptional()
@Transform(toNumberArray)
@IsArray()
@ArrayMaxSize(512)
@IsNumber({}, { each: true })
waveformPeaks?: number[];
@ApiPropertyOptional({ type: [String], description: 'Set image carousel URLs (max 10)' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(10)
@IsUrl({ require_tld: false }, { each: true })
imageUrls?: string[];
@ApiPropertyOptional({ type: [String], description: 'Set tagged user ids (max 20)' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(20)
@IsMongoId({ each: true })
taggedUserIds?: string[];
@ApiPropertyOptional({ type: [String], description: 'Set mention usernames like rami_sabry (max 30)' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(30)
@IsString({ each: true })
@Length(1, 30, { each: true })
mentionUsernames?: string[];
@ApiPropertyOptional({ description: 'Post location text' })
@IsOptional()
@IsString()
@Length(0, 120)
location?: string;
@ApiPropertyOptional({ description: 'Post latitude' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number;
@ApiPropertyOptional({ description: 'Post longitude' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number;
@ApiPropertyOptional({ enum: PostVisibility })
@IsOptional()
@IsEnum(PostVisibility)

عرض الملف

@@ -1,12 +1,34 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Query,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
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 { 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 { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
import { AdminPostQueryDto } from './dto/admin-post-query.dto';
import { CreateReelDto } from './dto/create-reel.dto';
import { CreatePostDto } from './dto/create-post.dto';
import { PostQueryDto } from './dto/post-query.dto';
import { ReelQueryDto } from './dto/reel-query.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { PostsService } from './posts.service';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@ApiTags('Posts')
@Controller('posts')
@@ -15,9 +37,58 @@ export class PostsController {
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseInterceptors(
FileFieldsInterceptor([
{ name: 'imageFiles', maxCount: 10 },
{ name: 'videoFile', maxCount: 1 },
{ name: 'audioFile', maxCount: 1 },
]),
)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
content: { type: 'string', example: 'First post #music' },
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
imageUrls: { type: 'array', items: { type: 'string' } },
taggedUserIds: { type: 'array', items: { type: 'string' } },
mentionUsernames: { type: 'array', items: { type: 'string' } },
location: { type: 'string', example: 'Riyadh, Saudi Arabia' },
latitude: { type: 'number', example: 24.7136 },
longitude: { type: 'number', example: 46.6753 },
videoUrl: { type: 'string', example: 'https://cdn.example.com/video.mp4' },
audioUrl: { type: 'string', example: 'https://cdn.example.com/audio.mp3' },
durationSeconds: { type: 'number', example: 54 },
thumbnailUrl: { type: 'string', example: 'https://cdn.example.com/cover.jpg' },
style: { type: 'string', example: 'Sharqi' },
maqam: { type: 'string', example: 'Hijaz' },
rhythmSignature: { type: 'string', example: '6/8' },
waveformPeaks: { type: 'array', items: { type: 'number' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
videoFile: { type: 'string', format: 'binary' },
audioFile: { type: 'string', format: 'binary' },
},
},
})
@Post()
async create(@CurrentUser() user: JwtPayload, @Body() dto: CreatePostDto) {
return this.postsService.create(user.sub, dto);
async create(
@CurrentUser() user: JwtPayload,
@Body() dto: CreatePostDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
videoFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
audioFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.postsService.create(
user.sub,
dto,
files?.imageFiles ?? [],
files?.videoFile?.[0],
files?.audioFile?.[0],
);
}
@ApiBearerAuth()
@@ -27,6 +98,58 @@ export class PostsController {
return this.postsService.findUserPosts(userId, query);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
@Get('admin/moderation')
async findPlatformPosts(@Query() query: AdminPostQueryDto) {
return this.postsService.findPlatformPosts(query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseInterceptors(
FileFieldsInterceptor([
{ name: 'videoFile', maxCount: 1 },
]),
)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
content: { type: 'string', example: 'New reel from oud session #reel' },
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
videoUrl: { type: 'string', example: 'https://cdn.example.com/reel.mp4' },
durationSeconds: { type: 'number', example: 42 },
thumbnailUrl: { type: 'string', example: 'https://cdn.example.com/reel-cover.jpg' },
style: { type: 'string', example: 'Sharqi' },
maqam: { type: 'string', example: 'Hijaz' },
rhythmSignature: { type: 'string', example: '6/8' },
mentionUsernames: { type: 'array', items: { type: 'string' } },
videoFile: { type: 'string', format: 'binary' },
},
},
})
@Post('reels')
async createReel(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateReelDto,
@UploadedFiles()
files?: {
videoFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.postsService.createReel(user.sub, dto, files?.videoFile?.[0]);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('reels')
async findReels(@Query() query: ReelQueryDto) {
return this.postsService.findReels(query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':postId')
@@ -36,13 +159,60 @@ export class PostsController {
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseInterceptors(
FileFieldsInterceptor([
{ name: 'imageFiles', maxCount: 10 },
{ name: 'videoFile', maxCount: 1 },
{ name: 'audioFile', maxCount: 1 },
]),
)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
content: { type: 'string', example: 'Updated content' },
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
imageUrls: { type: 'array', items: { type: 'string' } },
taggedUserIds: { type: 'array', items: { type: 'string' } },
mentionUsernames: { type: 'array', items: { type: 'string' } },
location: { type: 'string', example: 'Jeddah, Saudi Arabia' },
latitude: { type: 'number', example: 21.5433 },
longitude: { type: 'number', example: 39.1728 },
videoUrl: { type: 'string', example: 'https://cdn.example.com/video.mp4' },
audioUrl: { type: 'string', example: 'https://cdn.example.com/audio.mp3' },
durationSeconds: { type: 'number', example: 54 },
thumbnailUrl: { type: 'string', example: 'https://cdn.example.com/cover.jpg' },
style: { type: 'string', example: 'Sharqi' },
maqam: { type: 'string', example: 'Hijaz' },
rhythmSignature: { type: 'string', example: '6/8' },
waveformPeaks: { type: 'array', items: { type: 'number' } },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
videoFile: { type: 'string', format: 'binary' },
audioFile: { type: 'string', format: 'binary' },
},
},
})
@Patch(':postId')
async update(
@CurrentUser() user: JwtPayload,
@Param('postId') postId: string,
@Body() dto: UpdatePostDto,
@UploadedFiles()
files?: {
imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
videoFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
audioFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.postsService.update(user.sub, postId, dto);
return this.postsService.update(
user.sub,
postId,
dto,
files?.imageFiles ?? [],
files?.videoFile?.[0],
files?.audioFile?.[0],
);
}
@ApiBearerAuth()
@@ -52,4 +222,37 @@ export class PostsController {
await this.postsService.remove(user.sub, postId);
return { success: true };
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
@Delete('admin/:postId')
async removeBySuperAdmin(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
await this.postsService.removeBySuperAdmin(user.email ?? user.sub, postId);
return { success: true };
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post(':postId/view')
async registerView(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
return this.postsService.registerView(user.sub, postId);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post(':postId/play')
async registerPlay(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
return this.postsService.registerPlay(user.sub, postId);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post(':postId/share')
async registerShare(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
return this.postsService.registerShare(user.sub, postId);
}
}

عرض الملف

@@ -1,5 +1,7 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AuditModule } from '../audit/audit.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { UsersModule } from '../users/users.module';
import { Post, PostSchema } from './schemas/post.schema';
import { PostsController } from './posts.controller';
@@ -14,6 +16,8 @@ import { PostsService } from './posts.service';
schema: PostSchema,
},
]),
AuditModule,
NotificationsModule,
UsersModule,
],
controllers: [PostsController],

عرض الملف

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { ClientSession, FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { Post, PostDocument } from './schemas/post.schema';
@Injectable()
@@ -8,6 +9,14 @@ export class PostsRepository {
constructor(@InjectModel(Post.name) private readonly postModel: Model<PostDocument>) {}
private withActiveFilter<T extends FilterQuery<PostDocument>>(filter: T): FilterQuery<PostDocument> {
return {
...filter,
isDeleted: { $ne: true },
moderationStatus: { $ne: ModerationStatus.HIDDEN },
};
}
private withAdminFilter<T extends FilterQuery<PostDocument>>(filter: T): FilterQuery<PostDocument> {
return {
...filter,
isDeleted: { $ne: true },
@@ -28,6 +37,8 @@ export class PostsRepository {
return this.postModel
.findOne({ _id: new Types.ObjectId(postId), isDeleted: { $ne: true } })
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
.exec();
}
@@ -36,7 +47,11 @@ export class PostsRepository {
return null;
}
return this.postModel.findByIdAndUpdate(postId, payload, { new: true }).exec();
return this.postModel
.findByIdAndUpdate(postId, payload, { new: true })
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
.exec();
}
async deleteById(postId: string, deletedBy?: string): Promise<PostDocument | null> {
@@ -56,11 +71,33 @@ export class PostsRepository {
.exec();
}
async findMany(filter: FilterQuery<PostDocument>, skip: number, limit: number): Promise<PostDocument[]> {
async findMany(
filter: FilterQuery<PostDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<PostDocument[]> {
return this.postModel
.find(this.withActiveFilter(filter))
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
.sort({ createdAt: -1 })
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
}
async findManyAdmin(
filter: FilterQuery<PostDocument>,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<PostDocument[]> {
return this.postModel
.find(this.withAdminFilter(filter))
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
@@ -75,6 +112,7 @@ export class PostsRepository {
const rows = await this.postModel
.find({ _id: { $in: ids }, isDeleted: { $ne: true } })
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
.exec();
const order = new Map(postIds.map((id, idx) => [id, idx]));
@@ -103,6 +141,24 @@ export class PostsRepository {
.exec();
}
async incrementShareCount(postId: string, delta = 1, session?: ClientSession): Promise<void> {
await this.postModel
.findByIdAndUpdate(postId, { $inc: { shareCount: delta } }, { new: false, session })
.exec();
}
async incrementViewCount(postId: string, delta = 1, session?: ClientSession): Promise<void> {
await this.postModel
.findByIdAndUpdate(postId, { $inc: { viewCount: delta } }, { new: false, session })
.exec();
}
async incrementPlayCount(postId: string, delta = 1, session?: ClientSession): Promise<void> {
await this.postModel
.findByIdAndUpdate(postId, { $inc: { playCount: delta } }, { new: false, session })
.exec();
}
async setCommentsCount(postId: string, nextValue: number, session?: ClientSession): Promise<void> {
await this.postModel
.findByIdAndUpdate(
@@ -118,4 +174,30 @@ export class PostsRepository {
async count(filter: FilterQuery<PostDocument>): Promise<number> {
return this.postModel.countDocuments(this.withActiveFilter(filter)).exec();
}
async countAdmin(filter: FilterQuery<PostDocument>): Promise<number> {
return this.postModel.countDocuments(this.withAdminFilter(filter)).exec();
}
async updateModerationStatus(
postId: string,
payload: Pick<Post, 'moderationStatus' | 'moderationReason'>,
): Promise<PostDocument | null> {
if (!Types.ObjectId.isValid(postId)) {
return null;
}
return this.postModel
.findByIdAndUpdate(
postId,
{
moderationStatus: payload.moderationStatus,
moderationReason: payload.moderationReason,
},
{ new: true },
)
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
.exec();
}
}

تم حذف اختلاف الملف لأن الملف كبير جداً تحميل الاختلاف

عرض الملف

@@ -1,7 +1,12 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
import { PostType } from '../../../common/enums/post-type.enum';
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
import {
resolveManagedFileUrl,
resolveManagedFileUrls,
} from '../../../common/utils/public-url.util';
import { User } from '../../users/schemas/user.schema';
export type PostDocument = HydratedDocument<Post>;
@@ -20,6 +25,42 @@ export class Post {
@Prop({ default: '' })
audioUrl!: string;
@Prop({ type: Number, min: 1, max: 7200, default: null })
durationSeconds!: number | null;
@Prop({ default: '' })
thumbnailUrl!: string;
@Prop({ default: '', trim: true, maxlength: 80 })
style!: string;
@Prop({ default: '', trim: true, maxlength: 80 })
maqam!: string;
@Prop({ default: '', trim: true, maxlength: 40 })
rhythmSignature!: string;
@Prop({ type: [Number], default: [] })
waveformPeaks!: number[];
@Prop({ type: [String], default: [] })
imageUrls!: string[];
@Prop({ type: [Types.ObjectId], ref: User.name, default: [], index: true })
taggedUserIds!: Types.ObjectId[];
@Prop({ type: [String], default: [] })
mentionUsernames!: string[];
@Prop({ default: '' })
location!: string;
@Prop({ type: Number, min: -90, max: 90, default: null })
latitude!: number | null;
@Prop({ type: Number, min: -180, max: 180, default: null })
longitude!: number | null;
@Prop({ enum: PostType, default: PostType.TEXT, index: true })
postType!: PostType;
@@ -35,9 +76,29 @@ export class Post {
@Prop({ default: 0, min: 0 })
savesCount!: number;
@Prop({ default: 0, min: 0 })
shareCount!: number;
@Prop({ default: 0, min: 0 })
viewCount!: number;
@Prop({ default: 0, min: 0 })
playCount!: number;
@Prop({ type: [String], default: [], index: true })
hashtags!: string[];
@Prop({
type: String,
enum: Object.values(ModerationStatus),
default: ModerationStatus.ACTIVE,
index: true,
})
moderationStatus!: ModerationStatus;
@Prop({ default: '', maxlength: 300 })
moderationReason!: string;
@Prop({ default: false, index: true })
isDeleted!: boolean;
@@ -54,6 +115,29 @@ PostSchema.index({ authorId: 1, createdAt: -1 });
PostSchema.index({ visibility: 1, createdAt: -1 });
PostSchema.index({ postType: 1, createdAt: -1 });
PostSchema.index({ hashtags: 1, createdAt: -1 });
PostSchema.index({ taggedUserIds: 1, createdAt: -1 });
PostSchema.index({ moderationStatus: 1, createdAt: -1 });
PostSchema.index({ authorId: 1, isDeleted: 1, createdAt: -1 });
PostSchema.index({ visibility: 1, isDeleted: 1, createdAt: -1 });
PostSchema.index({ visibility: 1, isDeleted: 1, likesCount: -1, commentsCount: -1, savesCount: -1, createdAt: -1 });
PostSchema.index({
visibility: 1,
isDeleted: 1,
likesCount: -1,
commentsCount: -1,
savesCount: -1,
shareCount: -1,
viewCount: -1,
playCount: -1,
createdAt: -1,
});
const transformManagedPostFiles = (_doc: unknown, ret: any) => {
ret.imageUrls = resolveManagedFileUrls(ret.imageUrls);
ret.videoUrl = resolveManagedFileUrl(ret.videoUrl);
ret.audioUrl = resolveManagedFileUrl(ret.audioUrl);
ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl);
return ret;
};
PostSchema.set('toJSON', { transform: transformManagedPostFiles });
PostSchema.set('toObject', { transform: transformManagedPostFiles });

عرض الملف

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { NotificationsModule } from '../notifications/notifications.module';
import { PostsModule } from '../posts/posts.module';
import { Save, SaveSchema } from './schemas/save.schema';
import { SavesController } from './saves.controller';
@@ -10,9 +11,10 @@ import { SavesService } from './saves.service';
imports: [
MongooseModule.forFeature([{ name: Save.name, schema: SaveSchema }]),
PostsModule,
NotificationsModule,
],
controllers: [SavesController],
providers: [SavesService, SavesRepository],
exports: [SavesService],
exports: [SavesService, SavesRepository],
})
export class SavesModule {}

عرض الملف

@@ -20,14 +20,36 @@ export class SavesRepository {
});
}
async findSavedPostIds(userId: string, postIds: string[]): Promise<string[]> {
if (!postIds.length) {
return [];
}
const rows = await this.saveModel
.find({
userId: new Types.ObjectId(userId),
postId: { $in: postIds.map((id) => new Types.ObjectId(id)) },
})
.select({ postId: 1 })
.lean()
.exec();
return rows.map((row) => row.postId.toString());
}
async deleteById(id: string): Promise<void> {
await this.saveModel.findByIdAndDelete(id).exec();
}
async findUserSavedPostIds(userId: string, skip: number, limit: number): Promise<string[]> {
async findUserSavedPostIds(
userId: string,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<string[]> {
const rows = await this.saveModel
.find({ userId: new Types.ObjectId(userId) })
.sort({ createdAt: -1 })
.sort(sort)
.skip(skip)
.limit(limit)
.select('postId')

عرض الملف

@@ -12,6 +12,8 @@ describe('SavesService', () => {
const service = new SavesService(
savesRepository as any,
postsRepository as any,
{ bumpGlobalVersion: jest.fn() } as any,
{ createSaveNotification: jest.fn() } as any,
);
await expect(

عرض الملف

@@ -1,14 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { NotificationsService } from '../notifications/notifications.service';
import { PostsRepository } from '../posts/posts.repository';
import { ToggleSaveDto } from './dto/toggle-save.dto';
import { SavesRepository } from './saves.repository';
@Injectable()
export class SavesService {
private readonly logger = new Logger(SavesService.name);
constructor(
private readonly savesRepository: SavesRepository,
private readonly postsRepository: PostsRepository,
private readonly feedVersionService: FeedVersionService,
private readonly notificationsService: NotificationsService,
) {}
async toggle(userId: string, dto: ToggleSaveDto): Promise<{ saved: boolean; postId: string }> {
@@ -17,7 +26,7 @@ export class SavesService {
}
async save(userId: string, dto: ToggleSaveDto): Promise<{ saved: boolean; postId: string }> {
await this.assertPostExists(dto.postId);
const post = await this.getPostOrThrow(dto.postId);
const existing = await this.savesRepository.findOne(userId, dto.postId);
if (existing) {
@@ -26,6 +35,22 @@ export class SavesService {
await this.savesRepository.create(userId, dto.postId);
await this.postsRepository.incrementSavesCount(dto.postId, 1);
await this.feedVersionService.bumpGlobalVersion();
const recipientId = this.extractEntityId(post.authorId);
if (recipientId && recipientId !== userId) {
try {
await this.notificationsService.createSaveNotification(userId, recipientId, dto.postId, {
resourceType: 'post',
previewText: (post.content ?? '').slice(0, 140),
});
} catch (error) {
this.logger.warn(
`Save notification failed for actor=${userId} recipient=${recipientId}: ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
}
}
return { saved: true, postId: dto.postId };
}
@@ -39,6 +64,7 @@ export class SavesService {
await this.savesRepository.deleteById(existing.id);
await this.postsRepository.incrementSavesCount(dto.postId, -1);
await this.feedVersionService.bumpGlobalVersion();
return { saved: false, postId: dto.postId };
}
@@ -56,32 +82,66 @@ export class SavesService {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [postIds, total] = await Promise.all([
this.savesRepository.findUserSavedPostIds(userId, skip, limit),
this.savesRepository.findUserSavedPostIds(userId, skip, limit, sort),
this.savesRepository.countByUser(userId),
]);
const items = await this.postsRepository.findManyByIds(postIds);
return {
items,
return buildPaginatedResponse(items, {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
offset: skip,
});
}
private async assertPostExists(postId: string): Promise<void> {
const exists = await this.postExists(postId);
if (!exists) {
throw new NotFoundException('Post not found');
}
await this.getPostOrThrow(postId);
}
private async postExists(postId: string): Promise<boolean> {
const post = await this.postsRepository.findById(postId);
return !!post;
}
private async getPostOrThrow(postId: string) {
const post = await this.postsRepository.findById(postId);
if (!post) {
throw new NotFoundException('Post not found');
}
return post;
}
private extractEntityId(value: unknown): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (value instanceof Types.ObjectId) {
return value.toString();
}
if (typeof value === 'object') {
const candidate = value as { _id?: unknown; id?: unknown };
if (candidate._id instanceof Types.ObjectId) {
return candidate._id.toString();
}
if (typeof candidate._id === 'string') {
return candidate._id;
}
if (typeof candidate.id === 'string') {
return candidate.id;
}
}
return '';
}
}

عرض الملف

@@ -0,0 +1,41 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { ArrayMinSize, IsArray, IsBoolean, IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
import { toBoolean } from '../../../common/utils/query-transform.util';
import { SUPERADMIN_CASE_PRIORITIES } from '../schemas/superadmin-case.schema';
const BULK_RESOURCE_TYPES = ['post', 'comment', 'user', 'listing', 'repair_shop'] as const;
export class BulkSuperAdminActionDto {
@ApiProperty({ enum: BULK_RESOURCE_TYPES })
@IsIn(BULK_RESOURCE_TYPES)
resourceType!: (typeof BULK_RESOURCE_TYPES)[number];
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
targetIds!: string[];
@ApiProperty()
@IsString()
@MaxLength(60)
action!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(1200)
reason?: string;
@ApiPropertyOptional({ enum: SUPERADMIN_CASE_PRIORITIES })
@IsOptional()
@IsIn(SUPERADMIN_CASE_PRIORITIES)
priority?: (typeof SUPERADMIN_CASE_PRIORITIES)[number];
@ApiPropertyOptional()
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
assignToMe?: boolean;
}

عرض الملف

@@ -0,0 +1,83 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
import {
SUPERADMIN_CASE_PRIORITIES,
SUPERADMIN_CASE_STATUSES,
} from '../schemas/superadmin-case.schema';
const toStringArray = ({ value }: { value: unknown }): string[] => {
if (Array.isArray(value)) {
return value
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter(Boolean);
}
if (typeof value === 'string') {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
return [];
};
export class CreateSuperAdminCaseDto {
@ApiProperty()
@IsString()
@MaxLength(120)
title!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
@ApiProperty()
@IsString()
@MaxLength(80)
caseType!: string;
@ApiProperty()
@IsString()
@MaxLength(80)
resourceType!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(120)
resourceId?: string;
@ApiPropertyOptional({ enum: SUPERADMIN_CASE_STATUSES })
@IsOptional()
@IsIn(SUPERADMIN_CASE_STATUSES)
status?: (typeof SUPERADMIN_CASE_STATUSES)[number];
@ApiPropertyOptional({ enum: SUPERADMIN_CASE_PRIORITIES })
@IsOptional()
@IsIn(SUPERADMIN_CASE_PRIORITIES)
priority?: (typeof SUPERADMIN_CASE_PRIORITIES)[number];
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(160)
assignedTo?: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(12)
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(1200)
note?: string;
}

عرض الملف

@@ -0,0 +1,37 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsString } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import {
SUPERADMIN_CASE_PRIORITIES,
SUPERADMIN_CASE_STATUSES,
} from '../schemas/superadmin-case.schema';
export class SuperAdminCaseQueryDto extends PaginationQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ enum: SUPERADMIN_CASE_STATUSES })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
@IsIn(SUPERADMIN_CASE_STATUSES)
status?: (typeof SUPERADMIN_CASE_STATUSES)[number];
@ApiPropertyOptional({ enum: SUPERADMIN_CASE_PRIORITIES })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
@IsIn(SUPERADMIN_CASE_PRIORITIES)
priority?: (typeof SUPERADMIN_CASE_PRIORITIES)[number];
@ApiPropertyOptional()
@IsOptional()
@IsString()
resourceType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
assignedTo?: string;
}

عرض الملف

@@ -0,0 +1,11 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
const CHART_RANGES = ['7d', '30d', '90d'] as const;
export class SuperAdminChartsQueryDto {
@ApiPropertyOptional({ enum: CHART_RANGES, default: '30d' })
@IsOptional()
@IsIn(CHART_RANGES)
range?: (typeof CHART_RANGES)[number];
}

عرض الملف

@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class SuperAdminRecentActivityQueryDto {
@ApiPropertyOptional({ default: 12, minimum: 1, maximum: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit?: number;
}

عرض الملف

@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class SuperAdminReportsQueryDto {
@ApiPropertyOptional({ default: 8, minimum: 1, maximum: 25 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(25)
limit?: number;
}

عرض الملف

@@ -0,0 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
export class UpdateContentModerationStatusDto {
@ApiProperty({ enum: ModerationStatus })
@IsEnum(ModerationStatus)
status!: ModerationStatus;
@ApiPropertyOptional({ maxLength: 300 })
@IsOptional()
@IsString()
@MaxLength(300)
reason?: string;
}

عرض الملف

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateSuperAdminCaseDto } from './create-superadmin-case.dto';
export class UpdateSuperAdminCaseDto extends PartialType(CreateSuperAdminCaseDto) {}

عرض الملف

@@ -0,0 +1,61 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsBoolean, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
import { toStringArray } from '../../../common/utils/array-transform.util';
import { toBoolean } from '../../../common/utils/query-transform.util';
export class UpdateSuperAdminSettingsDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(120)
siteName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_tld: false })
publicBaseUrl?: string;
@ApiPropertyOptional()
@IsOptional()
@IsUrl({ require_tld: false })
dashboardApiBaseUrl?: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(20)
@IsString({ each: true })
corsOrigins?: string[];
@ApiPropertyOptional()
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
maintenanceMode?: boolean;
@ApiPropertyOptional()
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
emailEnabled?: boolean;
@ApiPropertyOptional()
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
marketplaceAutoApprove?: boolean;
@ApiPropertyOptional()
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
contentAutoHideFlagged?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string;
}

عرض الملف

@@ -0,0 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
import { toBoolean } from '../../../common/utils/query-transform.util';
export class UpdateSuperAdminUserStatusDto {
@ApiProperty()
@Transform(toBoolean)
@IsBoolean()
isDisabled!: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(300)
reason?: string;
}

عرض الملف

@@ -0,0 +1,83 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type SuperAdminCaseDocument = HydratedDocument<SuperAdminCase>;
export const SUPERADMIN_CASE_STATUSES = ['open', 'in_review', 'resolved'] as const;
export const SUPERADMIN_CASE_PRIORITIES = ['low', 'normal', 'high', 'critical'] as const;
export type SuperAdminCaseStatus = (typeof SUPERADMIN_CASE_STATUSES)[number];
export type SuperAdminCasePriority = (typeof SUPERADMIN_CASE_PRIORITIES)[number];
@Schema({ timestamps: true, versionKey: false })
export class SuperAdminCase {
@Prop({ required: true, trim: true, maxlength: 120, index: true })
title!: string;
@Prop({ default: '', trim: true, maxlength: 2000 })
description!: string;
@Prop({ required: true, trim: true, index: true })
caseType!: string;
@Prop({ required: true, trim: true, index: true })
resourceType!: string;
@Prop({ default: '', trim: true, index: true })
resourceId!: string;
@Prop({
required: true,
enum: SUPERADMIN_CASE_STATUSES,
default: 'open',
index: true,
})
status!: SuperAdminCaseStatus;
@Prop({
required: true,
enum: SUPERADMIN_CASE_PRIORITIES,
default: 'normal',
index: true,
})
priority!: SuperAdminCasePriority;
@Prop({ default: '', trim: true, index: true })
assignedTo!: string;
@Prop({ default: '', trim: true })
createdBy!: string;
@Prop({ default: '', trim: true })
updatedBy!: string;
@Prop({ type: [String], default: [] })
tags!: string[];
@Prop({ default: '', trim: true, maxlength: 1200 })
resolution!: string;
@Prop({
type: [
{
action: { type: String, required: true, trim: true },
actor: { type: String, required: true, trim: true },
note: { type: String, default: '', trim: true, maxlength: 1200 },
metadata: { type: Object, default: {} },
createdAt: { type: Date, default: Date.now },
},
],
default: [],
})
events!: Array<{
action: string;
actor: string;
note: string;
metadata?: Record<string, unknown>;
createdAt: Date;
}>;
}
export const SuperAdminCaseSchema = SchemaFactory.createForClass(SuperAdminCase);
SuperAdminCaseSchema.index({ status: 1, priority: 1, updatedAt: -1 });
SuperAdminCaseSchema.index({ resourceType: 1, resourceId: 1, updatedAt: -1 });

عرض الملف

@@ -0,0 +1,30 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type SuperAdminSettingsHistoryDocument = HydratedDocument<SuperAdminSettingsHistory>;
@Schema({ timestamps: true, versionKey: false })
export class SuperAdminSettingsHistory {
@Prop({ default: 'default', index: true })
scope!: string;
@Prop({ required: true, trim: true, maxlength: 160 })
updatedBy!: string;
@Prop({ type: [String], default: [] })
changedFields!: string[];
@Prop({ type: Object, required: true })
previousSettings!: Record<string, unknown>;
@Prop({ type: Object, required: true })
nextSettings!: Record<string, unknown>;
@Prop({ default: '', trim: true, maxlength: 1000 })
note!: string;
}
export const SuperAdminSettingsHistorySchema =
SchemaFactory.createForClass(SuperAdminSettingsHistory);
SuperAdminSettingsHistorySchema.index({ scope: 1, createdAt: -1 });

عرض الملف

@@ -0,0 +1,42 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type SuperAdminSettingsDocument = HydratedDocument<SuperAdminSettings>;
@Schema({ timestamps: true, versionKey: false })
export class SuperAdminSettings {
@Prop({ default: 'default', unique: true, index: true })
scope!: string;
@Prop({ default: 'Oudelaa SuperAdmin', trim: true, maxlength: 120 })
siteName!: string;
@Prop({ default: '', trim: true, maxlength: 200 })
publicBaseUrl!: string;
@Prop({ default: '', trim: true, maxlength: 200 })
dashboardApiBaseUrl!: string;
@Prop({ type: [String], default: [] })
corsOrigins!: string[];
@Prop({ default: false })
maintenanceMode!: boolean;
@Prop({ default: false })
emailEnabled!: boolean;
@Prop({ default: false })
marketplaceAutoApprove!: boolean;
@Prop({ default: false })
contentAutoHideFlagged!: boolean;
@Prop({ default: '', trim: true, maxlength: 1000 })
notes!: string;
@Prop({ default: '', trim: true, maxlength: 160 })
updatedBy!: string;
}
export const SuperAdminSettingsSchema = SchemaFactory.createForClass(SuperAdminSettings);

عرض الملف

@@ -0,0 +1,22 @@
export const SUPERADMIN_PERMISSIONS = {
OVERVIEW_READ: 'overview.read',
ANALYTICS_READ: 'analytics.read',
USERS_READ: 'users.read',
USERS_MANAGE: 'users.manage',
CONTENT_MODERATE: 'content.moderate',
MARKETPLACE_MANAGE: 'marketplace.manage',
NOTIFICATIONS_READ: 'notifications.read',
AUDIT_READ: 'audit.read',
SETTINGS_READ: 'settings.read',
SETTINGS_WRITE: 'settings.write',
SESSIONS_MANAGE: 'sessions.manage',
OPS_READ: 'ops.read',
CASES_MANAGE: 'cases.manage',
} as const;
export type SuperAdminPermission =
(typeof SUPERADMIN_PERMISSIONS)[keyof typeof SUPERADMIN_PERMISSIONS];
export const DEFAULT_SUPERADMIN_PERMISSIONS: SuperAdminPermission[] = Object.values(
SUPERADMIN_PERMISSIONS,
);

عرض الملف

@@ -0,0 +1,184 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
import { CurrentUser } from '../../common/decorators/current-user.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 { BulkSuperAdminActionDto } from './dto/bulk-superadmin-action.dto';
import { CreateSuperAdminCaseDto } from './dto/create-superadmin-case.dto';
import { SuperAdminCaseQueryDto } from './dto/superadmin-case-query.dto';
import { SuperAdminChartsQueryDto } from './dto/superadmin-charts-query.dto';
import { SuperAdminRecentActivityQueryDto } from './dto/superadmin-recent-activity-query.dto';
import { SuperAdminReportsQueryDto } from './dto/superadmin-reports-query.dto';
import { UpdateContentModerationStatusDto } from './dto/update-content-moderation-status.dto';
import { UpdateSuperAdminCaseDto } from './dto/update-superadmin-case.dto';
import { UpdateSuperAdminSettingsDto } from './dto/update-superadmin-settings.dto';
import { UpdateSuperAdminUserStatusDto } from './dto/update-superadmin-user-status.dto';
import { SUPERADMIN_PERMISSIONS } from './superadmin-permissions';
import { SuperAdminService } from './superadmin.service';
@ApiTags('SuperAdmin')
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@Controller('superadmin')
export class SuperAdminController {
constructor(private readonly superAdminService: SuperAdminService) {}
@Get('session')
getSession(@CurrentUser() user: JwtPayload) {
return this.superAdminService.getSession(user);
}
@Get('overview')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.OVERVIEW_READ)
async getOverview() {
return this.superAdminService.getOverview();
}
@Get('charts')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.ANALYTICS_READ)
async getCharts(@Query() query: SuperAdminChartsQueryDto) {
return this.superAdminService.getCharts(query);
}
@Get('recent-activity')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.ANALYTICS_READ)
async getRecentActivity(@Query() query: SuperAdminRecentActivityQueryDto) {
return this.superAdminService.getRecentActivity(query);
}
@Get('reports')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.ANALYTICS_READ)
async getReports(@Query() query: SuperAdminReportsQueryDto) {
return this.superAdminService.getReports(query);
}
@Get('ops')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.OPS_READ)
async getOps() {
return this.superAdminService.getOps();
}
@Get('cases')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
async getCases(@Query() query: SuperAdminCaseQueryDto) {
return this.superAdminService.getCases(query);
}
@Post('cases')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
async createCase(@CurrentUser() user: JwtPayload, @Body() dto: CreateSuperAdminCaseDto) {
return this.superAdminService.createCase(user.email ?? user.sub, dto);
}
@Patch('cases/:caseId')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
async updateCase(
@CurrentUser() user: JwtPayload,
@Param('caseId') caseId: string,
@Body() dto: UpdateSuperAdminCaseDto,
) {
return this.superAdminService.updateCase(user.email ?? user.sub, caseId, dto);
}
@Post('bulk-actions')
@SuperAdminPermissions(
SUPERADMIN_PERMISSIONS.CASES_MANAGE,
SUPERADMIN_PERMISSIONS.CONTENT_MODERATE,
)
async performBulkAction(
@CurrentUser() user: JwtPayload,
@Body() dto: BulkSuperAdminActionDto,
) {
return this.superAdminService.performBulkAction(user.email ?? user.sub, dto);
}
@Get('settings')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SETTINGS_READ)
async getSettings(): Promise<Record<string, unknown>> {
return this.superAdminService.getSettings();
}
@Get('settings/history')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SETTINGS_READ)
async getSettingsHistory(@Query() query: PaginationQueryDto) {
return this.superAdminService.getSettingsHistory(query);
}
@Patch('settings')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SETTINGS_WRITE)
async updateSettings(
@CurrentUser() user: JwtPayload,
@Body() dto: UpdateSuperAdminSettingsDto,
): Promise<Record<string, unknown>> {
return this.superAdminService.updateSettings(user.email ?? user.sub, dto);
}
@Post('settings/history/:historyId/restore')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.SETTINGS_WRITE)
async restoreSettingsVersion(
@CurrentUser() user: JwtPayload,
@Param('historyId') historyId: string,
) {
return this.superAdminService.restoreSettingsVersion(user.email ?? user.sub, historyId);
}
@Patch('posts/:postId/status')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
async updatePostStatus(
@CurrentUser() user: JwtPayload,
@Param('postId') postId: string,
@Body() dto: UpdateContentModerationStatusDto,
) {
return this.superAdminService.updatePostStatus(user.email ?? user.sub, postId, dto);
}
@Delete('posts/:postId')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
async deletePost(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
return this.superAdminService.deletePost(user.email ?? user.sub, postId);
}
@Post('posts/:postId/restore')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
async restorePost(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
return this.superAdminService.restorePost(user.email ?? user.sub, postId);
}
@Patch('comments/:commentId/status')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
async updateCommentStatus(
@CurrentUser() user: JwtPayload,
@Param('commentId') commentId: string,
@Body() dto: UpdateContentModerationStatusDto,
) {
return this.superAdminService.updateCommentStatus(user.email ?? user.sub, commentId, dto);
}
@Delete('comments/:commentId')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
async deleteComment(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
return this.superAdminService.deleteComment(user.email ?? user.sub, commentId);
}
@Post('comments/:commentId/restore')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
async restoreComment(
@CurrentUser() user: JwtPayload,
@Param('commentId') commentId: string,
) {
return this.superAdminService.restoreComment(user.email ?? user.sub, commentId);
}
@Patch('users/:userId/status')
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.USERS_MANAGE)
async updateUserStatus(
@CurrentUser() user: JwtPayload,
@Param('userId') userId: string,
@Body() dto: UpdateSuperAdminUserStatusDto,
) {
return this.superAdminService.updateUserStatus(user.email ?? user.sub, userId, dto);
}
}

عرض الملف

@@ -0,0 +1,56 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AuditModule } from '../audit/audit.module';
import { CommentsModule } from '../comments/comments.module';
import { Comment, CommentSchema } from '../comments/schemas/comment.schema';
import { Instrument, InstrumentSchema } from '../marketplace/schemas/instrument.schema';
import { RepairShop, RepairShopSchema } from '../marketplace/schemas/repair-shop.schema';
import { Notification, NotificationSchema } from '../notifications/schemas/notification.schema';
import { PostsModule } from '../posts/posts.module';
import { Post, PostSchema } from '../posts/schemas/post.schema';
import { UsersModule } from '../users/users.module';
import { User, UserSchema } from '../users/schemas/user.schema';
import { AuditLog, AuditLogSchema } from '../audit/schemas/audit-log.schema';
import { SuperAdminController } from './superadmin.controller';
import { SuperAdminService } from './superadmin.service';
import {
SuperAdminSettings,
SuperAdminSettingsSchema,
} from './schemas/superadmin-settings.schema';
import {
SuperAdminSettingsHistory,
SuperAdminSettingsHistorySchema,
} from './schemas/superadmin-settings-history.schema';
import { SuperAdminCase, SuperAdminCaseSchema } from './schemas/superadmin-case.schema';
import { OutboxEvent, OutboxEventSchema } from '../outbox/schemas/outbox-event.schema';
import {
SuperAdminRefreshToken,
SuperAdminRefreshTokenSchema,
} from '../auth/schemas/super-admin-refresh-token.schema';
@Module({
imports: [
MongooseModule.forFeature([
{ name: User.name, schema: UserSchema },
{ name: Post.name, schema: PostSchema },
{ name: Comment.name, schema: CommentSchema },
{ name: Instrument.name, schema: InstrumentSchema },
{ name: RepairShop.name, schema: RepairShopSchema },
{ name: Notification.name, schema: NotificationSchema },
{ name: AuditLog.name, schema: AuditLogSchema },
{ name: SuperAdminSettings.name, schema: SuperAdminSettingsSchema },
{ name: SuperAdminSettingsHistory.name, schema: SuperAdminSettingsHistorySchema },
{ name: SuperAdminCase.name, schema: SuperAdminCaseSchema },
{ name: OutboxEvent.name, schema: OutboxEventSchema },
{ name: SuperAdminRefreshToken.name, schema: SuperAdminRefreshTokenSchema },
]),
AuditModule,
PostsModule,
CommentsModule,
UsersModule,
],
controllers: [SuperAdminController],
providers: [SuperAdminService],
exports: [SuperAdminService],
})
export class SuperAdminModule {}

تم حذف اختلاف الملف لأن الملف كبير جداً تحميل الاختلاف

عرض الملف

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

لم تُعرض بعض الملفات لأن الكثير من الملفات تغيرت في هذا الاختلاف إظهار المزيد