first commit
هذا الالتزام موجود في:
50
src/modules/comments/comments.controller.ts
Normal file
50
src/modules/comments/comments.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
20
src/modules/comments/comments.module.ts
Normal file
20
src/modules/comments/comments.module.ts
Normal file
@@ -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 {}
|
||||
77
src/modules/comments/comments.repository.ts
Normal file
77
src/modules/comments/comments.repository.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
114
src/modules/comments/comments.service.ts
Normal file
114
src/modules/comments/comments.service.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
3
src/modules/comments/dto/comment-query.dto.ts
Normal file
3
src/modules/comments/dto/comment-query.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export class CommentQueryDto extends PaginationQueryDto {}
|
||||
18
src/modules/comments/dto/create-comment.dto.ts
Normal file
18
src/modules/comments/dto/create-comment.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
8
src/modules/comments/dto/update-comment.dto.ts
Normal file
8
src/modules/comments/dto/update-comment.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsOptional, IsString, Length } from 'class-validator';
|
||||
|
||||
export class UpdateCommentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 1000)
|
||||
content?: string;
|
||||
}
|
||||
34
src/modules/comments/schemas/comment.schema.ts
Normal file
34
src/modules/comments/schemas/comment.schema.ts
Normal file
@@ -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 });
|
||||
المرجع في مشكلة جديدة
حظر مستخدم