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

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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