feat: expand backend admin marketplace and scaling
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
هذا الالتزام موجود في:
22
src/modules/audit/audit.controller.ts
Normal file
22
src/modules/audit/audit.controller.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
22
src/modules/audit/dto/audit-query.dto.ts
Normal file
22
src/modules/audit/dto/audit-query.dto.ts
Normal file
@@ -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 '';
|
||||
}
|
||||
}
|
||||
|
||||
26
src/modules/comments/dto/admin-comment-query.dto.ts
Normal file
26
src/modules/comments/dto/admin-comment-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
74
src/modules/marketplace/dto/create-repair-shop.dto.ts
Normal file
74
src/modules/marketplace/dto/create-repair-shop.dto.ts
Normal file
@@ -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()
|
||||
|
||||
36
src/modules/marketplace/dto/marketplace-home-query.dto.ts
Normal file
36
src/modules/marketplace/dto/marketplace-home-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
33
src/modules/marketplace/dto/repair-shop-query.dto.ts
Normal file
33
src/modules/marketplace/dto/repair-shop-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
14
src/modules/marketplace/dto/update-marketplace-status.dto.ts
Normal file
14
src/modules/marketplace/dto/update-marketplace-status.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
4
src/modules/marketplace/dto/update-repair-shop.dto.ts
Normal file
4
src/modules/marketplace/dto/update-repair-shop.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateRepairShopDto } from './create-repair-shop.dto';
|
||||
|
||||
export class UpdateRepairShopDto extends PartialType(CreateRepairShopDto) {}
|
||||
51
src/modules/marketplace/dto/update-shop-profile.dto.ts
Normal file
51
src/modules/marketplace/dto/update-shop-profile.dto.ts
Normal file
@@ -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 });
|
||||
|
||||
55
src/modules/marketplace/schemas/repair-shop.schema.ts
Normal file
55
src/modules/marketplace/schemas/repair-shop.schema.ts
Normal file
@@ -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 });
|
||||
23
src/modules/media/dto/text-to-music.dto.ts
Normal file
23
src/modules/media/dto/text-to-music.dto.ts
Normal file
@@ -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();
|
||||
|
||||
16
src/modules/posts/dto/admin-post-query.dto.ts
Normal file
16
src/modules/posts/dto/admin-post-query.dto.ts
Normal file
@@ -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)
|
||||
|
||||
73
src/modules/posts/dto/create-reel.dto.ts
Normal file
73
src/modules/posts/dto/create-reel.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
27
src/modules/posts/dto/reel-query.dto.ts
Normal file
27
src/modules/posts/dto/reel-query.dto.ts
Normal file
@@ -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 '';
|
||||
}
|
||||
}
|
||||
|
||||
41
src/modules/superadmin/dto/bulk-superadmin-action.dto.ts
Normal file
41
src/modules/superadmin/dto/bulk-superadmin-action.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
83
src/modules/superadmin/dto/create-superadmin-case.dto.ts
Normal file
83
src/modules/superadmin/dto/create-superadmin-case.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
37
src/modules/superadmin/dto/superadmin-case-query.dto.ts
Normal file
37
src/modules/superadmin/dto/superadmin-case-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
11
src/modules/superadmin/dto/superadmin-charts-query.dto.ts
Normal file
11
src/modules/superadmin/dto/superadmin-charts-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
13
src/modules/superadmin/dto/superadmin-reports-query.dto.ts
Normal file
13
src/modules/superadmin/dto/superadmin-reports-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
4
src/modules/superadmin/dto/update-superadmin-case.dto.ts
Normal file
4
src/modules/superadmin/dto/update-superadmin-case.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateSuperAdminCaseDto } from './create-superadmin-case.dto';
|
||||
|
||||
export class UpdateSuperAdminCaseDto extends PartialType(CreateSuperAdminCaseDto) {}
|
||||
61
src/modules/superadmin/dto/update-superadmin-settings.dto.ts
Normal file
61
src/modules/superadmin/dto/update-superadmin-settings.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
83
src/modules/superadmin/schemas/superadmin-case.schema.ts
Normal file
83
src/modules/superadmin/schemas/superadmin-case.schema.ts
Normal file
@@ -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 });
|
||||
42
src/modules/superadmin/schemas/superadmin-settings.schema.ts
Normal file
42
src/modules/superadmin/schemas/superadmin-settings.schema.ts
Normal file
@@ -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);
|
||||
22
src/modules/superadmin/superadmin-permissions.ts
Normal file
22
src/modules/superadmin/superadmin-permissions.ts
Normal file
@@ -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,
|
||||
);
|
||||
184
src/modules/superadmin/superadmin.controller.ts
Normal file
184
src/modules/superadmin/superadmin.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
56
src/modules/superadmin/superadmin.module.ts
Normal file
56
src/modules/superadmin/superadmin.module.ts
Normal file
@@ -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 {}
|
||||
1657
src/modules/superadmin/superadmin.service.ts
Normal file
1657
src/modules/superadmin/superadmin.service.ts
Normal file
تم حذف اختلاف الملف لأن الملف كبير جداً
تحميل الاختلاف
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from 'class-validator';
|
||||
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
|
||||
import { MusicRole } from '../../../common/enums/music-role.enum';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({ example: 'John Doe' })
|
||||
@@ -54,6 +56,11 @@ export class CreateUserDto {
|
||||
@IsUrl({ require_tld: false })
|
||||
avatar?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 'https://cdn.example.com/profile-cover.jpg' })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false })
|
||||
coverImage?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 'Riyadh, Saudi Arabia' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -76,11 +83,13 @@ export class CreateUserDto {
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isPrivate?: boolean;
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isVerified?: boolean;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsNumber, IsOptional, IsString, IsUrl, Length, Max, Min } from 'class-validator';
|
||||
|
||||
@@ -14,7 +14,12 @@ export class ProfileSetupDto {
|
||||
@IsUrl({ require_tld: false })
|
||||
avatar?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '<EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD>', maxLength: 150 })
|
||||
@ApiPropertyOptional({ example: 'https://cdn.example.com/profile-cover.jpg' })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false })
|
||||
coverImage?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Short bio about me', maxLength: 150 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 150)
|
||||
|
||||
27
src/modules/users/dto/talent-discover-query.dto.ts
Normal file
27
src/modules/users/dto/talent-discover-query.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||
import { UserQueryDto } from './user-query.dto';
|
||||
|
||||
export class TalentDiscoverQueryDto extends UserQueryDto {
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
hasAvatarOnly?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
includeRoleBuckets?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 24, default: 8 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(24)
|
||||
limit?: number;
|
||||
}
|
||||
لم تُعرض بعض الملفات لأن الكثير من الملفات تغيرت في هذا الاختلاف إظهار المزيد
المرجع في مشكلة جديدة
حظر مستخدم