هذا الالتزام موجود في:
2026-04-20 15:12:16 +03:00
التزام 28f7241bcd
172 ملفات معدلة مع 21907 إضافات و0 حذوفات

عرض الملف

@@ -0,0 +1,50 @@
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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { CommentQueryDto } from './dto/comment-query.dto';
import { CreateCommentDto } from './dto/create-comment.dto';
import { CommentsService } from './comments.service';
@ApiTags('Comments')
@Controller('comments')
export class CommentsController {
constructor(private readonly commentsService: CommentsService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post()
async create(@CurrentUser() user: JwtPayload, @Body() dto: CreateCommentDto) {
return this.commentsService.create(user.sub, dto);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('post/:postId')
async findByPost(@Param('postId') postId: string, @Query() query: CommentQueryDto) {
return this.commentsService.findByPost(postId, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':commentId/replies')
async findReplies(@Param('commentId') commentId: string, @Query() query: CommentQueryDto) {
return this.commentsService.findReplies(commentId, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Delete(':commentId')
async remove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
return this.commentsService.remove(user.sub, commentId);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Delete('admin/:commentId')
async adminRemove(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
return this.commentsService.removeBySuperAdmin(user.email ?? user.sub, commentId);
}
}

عرض الملف

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AuditModule } from '../audit/audit.module';
import { PostsModule } from '../posts/posts.module';
import { Comment, CommentSchema } from './schemas/comment.schema';
import { CommentsController } from './comments.controller';
import { CommentsService } from './comments.service';
import { CommentsRepository } from './comments.repository';
@Module({
imports: [
AuditModule,
MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]),
PostsModule,
],
controllers: [CommentsController],
providers: [CommentsService, CommentsRepository],
exports: [CommentsService, CommentsRepository],
})
export class CommentsModule {}

عرض الملف

@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
import { Comment, CommentDocument } from './schemas/comment.schema';
@Injectable()
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 },
};
}
async create(
payload: { postId: string; authorId: string; content: string; parentCommentId?: string },
session?: ClientSession,
) {
return this.commentModel.create({
postId: new Types.ObjectId(payload.postId),
authorId: new Types.ObjectId(payload.authorId),
content: payload.content,
...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}),
}, { session });
}
async findById(commentId: string): Promise<CommentDocument | null> {
if (!Types.ObjectId.isValid(commentId)) {
return null;
}
return this.commentModel
.findOne({ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } })
.exec();
}
async deleteById(commentId: string, deletedBy?: string, session?: ClientSession): Promise<boolean> {
if (!Types.ObjectId.isValid(commentId)) {
return false;
}
const deletedByObjectId =
deletedBy && Types.ObjectId.isValid(deletedBy) ? new Types.ObjectId(deletedBy) : null;
const updated = await this.commentModel
.findOneAndUpdate(
{ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } },
{ isDeleted: true, deletedAt: new Date(), deletedBy: deletedByObjectId },
{ new: false, session },
)
.exec();
return !!updated;
}
async findMany(filter: FilterQuery<CommentDocument>, skip: number, limit: number) {
return this.commentModel
.find(this.withActiveFilter(filter))
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.exec();
}
async count(filter: FilterQuery<CommentDocument>): Promise<number> {
return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec();
}
async countByPost(postId: string): Promise<number> {
return this.commentModel
.countDocuments({ postId: new Types.ObjectId(postId), isDeleted: { $ne: true } })
.exec();
}
}

عرض الملف

@@ -0,0 +1,114 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { AuditService } from '../audit/audit.service';
import { PostsRepository } from '../posts/posts.repository';
import { CommentQueryDto } from './dto/comment-query.dto';
import { CreateCommentDto } from './dto/create-comment.dto';
import { CommentsRepository } from './comments.repository';
@Injectable()
export class CommentsService {
constructor(
private readonly commentsRepository: CommentsRepository,
private readonly postsRepository: PostsRepository,
private readonly auditService: AuditService,
) {}
async create(userId: string, dto: CreateCommentDto) {
const post = await this.postsRepository.findById(dto.postId);
if (!post) {
throw new NotFoundException('Post not found');
}
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');
}
}
const comment = await this.commentsRepository.create({
postId: dto.postId,
authorId: userId,
content: dto.content,
parentCommentId: dto.parentCommentId,
});
await this.syncCommentsCount(dto.postId);
return comment;
}
async remove(userId: string, commentId: string) {
const comment = await this.commentsRepository.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
if (comment.authorId.toString() !== userId) {
throw new ForbiddenException('You can only delete your own comments');
}
await this.commentsRepository.deleteById(commentId, userId);
await this.syncCommentsCount(comment.postId.toString());
return { success: true };
}
async removeBySuperAdmin(superAdminIdentifier: string, commentId: string) {
const comment = await this.commentsRepository.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
await this.commentsRepository.deleteById(commentId, superAdminIdentifier);
await this.syncCommentsCount(comment.postId.toString());
await this.auditService.logSuperAdminAction(
superAdminIdentifier,
'comment_delete',
'comment',
commentId,
{ postId: comment.postId.toString() },
);
return { success: true, message: 'Comment deleted by superadmin' };
}
async findByPost(postId: string, query: CommentQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
this.commentsRepository.findMany({ postId, parentCommentId: { $exists: false } }, skip, limit),
this.commentsRepository.count({ postId, parentCommentId: { $exists: false } }),
]);
return {
items,
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
}
async findReplies(parentCommentId: string, query: CommentQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
this.commentsRepository.findMany({ parentCommentId }, skip, limit),
this.commentsRepository.count({ parentCommentId }),
]);
return {
items,
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
};
}
private async syncCommentsCount(postId: string): Promise<void> {
const totalComments = await this.commentsRepository.countByPost(postId);
await this.postsRepository.setCommentsCount(postId, totalComments);
}
}

عرض الملف

@@ -0,0 +1,3 @@
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
export class CommentQueryDto extends PaginationQueryDto {}

عرض الملف

@@ -0,0 +1,18 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsMongoId, IsOptional, IsString, Length } from 'class-validator';
export class CreateCommentDto {
@ApiProperty()
@IsMongoId()
postId!: string;
@ApiProperty()
@IsString()
@Length(1, 1000)
content!: string;
@ApiPropertyOptional()
@IsOptional()
@IsMongoId()
parentCommentId?: string;
}

عرض الملف

@@ -0,0 +1,8 @@
import { IsOptional, IsString, Length } from 'class-validator';
export class UpdateCommentDto {
@IsOptional()
@IsString()
@Length(1, 1000)
content?: string;
}

عرض الملف

@@ -0,0 +1,34 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { Post } from '../../posts/schemas/post.schema';
import { User } from '../../users/schemas/user.schema';
export type CommentDocument = HydratedDocument<Comment>;
@Schema({ timestamps: true, versionKey: false })
export class Comment {
@Prop({ type: Types.ObjectId, ref: Post.name, required: true, index: true })
postId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
authorId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, required: false, index: true })
parentCommentId?: Types.ObjectId;
@Prop({ required: true, maxlength: 1000 })
content!: string;
@Prop({ default: false, index: true })
isDeleted!: boolean;
@Prop({ type: Date, default: null })
deletedAt?: Date | null;
@Prop({ type: Types.ObjectId, ref: User.name, default: null })
deletedBy?: Types.ObjectId | null;
}
export const CommentSchema = SchemaFactory.createForClass(Comment);
CommentSchema.index({ postId: 1, createdAt: -1 });
CommentSchema.index({ postId: 1, parentCommentId: 1, isDeleted: 1, createdAt: -1 });