Add Instagram-style social features and Postman collections
هذا الالتزام موجود في:
@@ -9,6 +9,7 @@ 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 { CreateCommentReplyDto } from './dto/create-comment-reply.dto';
|
||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { CommentsService } from './comments.service';
|
||||
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
@@ -28,15 +29,48 @@ export class CommentsController {
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('post/:postId')
|
||||
async findByPost(@Param('postId') postId: string, @Query() query: CommentQueryDto) {
|
||||
return this.commentsService.findByPost(postId, query);
|
||||
async findByPost(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('postId') postId: string,
|
||||
@Query() query: CommentQueryDto,
|
||||
) {
|
||||
return this.commentsService.findByPost(user.sub, postId, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post(':commentId/replies')
|
||||
async createReply(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() dto: CreateCommentReplyDto,
|
||||
) {
|
||||
return this.commentsService.createReply(user.sub, commentId, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get(':commentId/replies')
|
||||
async findReplies(@Param('commentId') commentId: string, @Query() query: CommentQueryDto) {
|
||||
return this.commentsService.findReplies(commentId, query);
|
||||
async findReplies(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('commentId') commentId: string,
|
||||
@Query() query: CommentQueryDto,
|
||||
) {
|
||||
return this.commentsService.findReplies(user.sub, commentId, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':commentId/pin')
|
||||
async pin(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
|
||||
return this.commentsService.pin(user.sub, commentId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':commentId/unpin')
|
||||
async unpin(@CurrentUser() user: JwtPayload, @Param('commentId') commentId: string) {
|
||||
return this.commentsService.unpin(user.sub, commentId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -4,6 +4,8 @@ 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 { Like, LikeSchema } from '../likes/schemas/like.schema';
|
||||
import { FollowsModule } from '../follows/follows.module';
|
||||
import { Comment, CommentSchema } from './schemas/comment.schema';
|
||||
import { CommentsController } from './comments.controller';
|
||||
import { CommentsService } from './comments.service';
|
||||
@@ -12,10 +14,14 @@ import { CommentsRepository } from './comments.repository';
|
||||
@Module({
|
||||
imports: [
|
||||
AuditModule,
|
||||
MongooseModule.forFeature([{ name: Comment.name, schema: CommentSchema }]),
|
||||
MongooseModule.forFeature([
|
||||
{ name: Comment.name, schema: CommentSchema },
|
||||
{ name: Like.name, schema: LikeSchema },
|
||||
]),
|
||||
PostsModule,
|
||||
NotificationsModule,
|
||||
UsersModule,
|
||||
FollowsModule,
|
||||
],
|
||||
controllers: [CommentsController],
|
||||
providers: [CommentsService, CommentsRepository],
|
||||
|
||||
@@ -2,16 +2,21 @@ 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 { Like, LikeDocument } from '../likes/schemas/like.schema';
|
||||
import { Comment, CommentDocument } from './schemas/comment.schema';
|
||||
|
||||
@Injectable()
|
||||
export class CommentsRepository {
|
||||
constructor(@InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>) {}
|
||||
constructor(
|
||||
@InjectModel(Comment.name) private readonly commentModel: Model<CommentDocument>,
|
||||
@InjectModel(Like.name) private readonly likeModel: Model<LikeDocument>,
|
||||
) {}
|
||||
|
||||
private withActiveFilter<T extends FilterQuery<CommentDocument>>(filter: T): FilterQuery<CommentDocument> {
|
||||
return {
|
||||
...filter,
|
||||
isDeleted: { $ne: true },
|
||||
hiddenByFilter: { $ne: true },
|
||||
moderationStatus: { $ne: ModerationStatus.HIDDEN },
|
||||
};
|
||||
}
|
||||
@@ -30,6 +35,8 @@ export class CommentsRepository {
|
||||
content: string;
|
||||
mentionUsernames?: string[];
|
||||
parentCommentId?: string;
|
||||
hiddenByFilter?: boolean;
|
||||
hiddenReason?: string;
|
||||
},
|
||||
session?: ClientSession,
|
||||
) {
|
||||
@@ -38,6 +45,8 @@ export class CommentsRepository {
|
||||
authorId: new Types.ObjectId(payload.authorId),
|
||||
content: payload.content,
|
||||
mentionUsernames: payload.mentionUsernames ?? [],
|
||||
hiddenByFilter: payload.hiddenByFilter ?? false,
|
||||
hiddenReason: payload.hiddenReason ?? '',
|
||||
...(payload.parentCommentId ? { parentCommentId: new Types.ObjectId(payload.parentCommentId) } : {}),
|
||||
});
|
||||
|
||||
@@ -54,6 +63,17 @@ export class CommentsRepository {
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findByIdWithAuthor(commentId: string): Promise<CommentDocument | null> {
|
||||
if (!Types.ObjectId.isValid(commentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.commentModel
|
||||
.findOne({ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } })
|
||||
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async deleteById(commentId: string, deletedBy?: string, session?: ClientSession): Promise<boolean> {
|
||||
if (!Types.ObjectId.isValid(commentId)) {
|
||||
return false;
|
||||
@@ -104,6 +124,80 @@ export class CommentsRepository {
|
||||
.exec();
|
||||
}
|
||||
|
||||
async setPinned(commentId: string, isPinned: boolean): Promise<CommentDocument | null> {
|
||||
if (!Types.ObjectId.isValid(commentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.commentModel
|
||||
.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } },
|
||||
{ isPinned },
|
||||
{ new: true },
|
||||
)
|
||||
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findManyTop(
|
||||
filter: FilterQuery<CommentDocument>,
|
||||
skip: number,
|
||||
limit: number,
|
||||
createdAtDirection: 1 | -1 = -1,
|
||||
): Promise<CommentDocument[]> {
|
||||
const rows = await this.commentModel
|
||||
.aggregate<{ _id: Types.ObjectId }>([
|
||||
{ $match: this.withActiveFilter(filter) },
|
||||
{
|
||||
$lookup: {
|
||||
from: this.likeModel.collection.name,
|
||||
let: { commentId: '$_id' },
|
||||
pipeline: [
|
||||
{
|
||||
$match: {
|
||||
$expr: {
|
||||
$and: [
|
||||
{ $eq: ['$targetId', '$$commentId'] },
|
||||
{ $eq: ['$targetType', 'comment'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $project: { _id: 1 } },
|
||||
],
|
||||
as: 'commentLikes',
|
||||
},
|
||||
},
|
||||
{ $addFields: { likesCountForSort: { $size: '$commentLikes' } } },
|
||||
{ $sort: { likesCountForSort: -1, createdAt: createdAtDirection } },
|
||||
{ $skip: skip },
|
||||
{ $limit: limit },
|
||||
{ $project: { _id: 1 } },
|
||||
])
|
||||
.exec();
|
||||
|
||||
const ids = rows.map((row) => row._id);
|
||||
if (!ids.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const docs = await this.commentModel
|
||||
.find({ _id: { $in: ids } })
|
||||
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
|
||||
.exec();
|
||||
const docsById = new Map(docs.map((doc) => [doc.id, doc]));
|
||||
|
||||
const orderedDocs: CommentDocument[] = [];
|
||||
for (const id of ids) {
|
||||
const doc = docsById.get(id.toString());
|
||||
if (doc) {
|
||||
orderedDocs.push(doc);
|
||||
}
|
||||
}
|
||||
|
||||
return orderedDocs;
|
||||
}
|
||||
|
||||
async findManyAdmin(
|
||||
filter: FilterQuery<CommentDocument>,
|
||||
skip: number,
|
||||
@@ -123,6 +217,86 @@ export class CommentsRepository {
|
||||
return this.commentModel.countDocuments(this.withActiveFilter(filter)).exec();
|
||||
}
|
||||
|
||||
async countRepliesByParentIds(parentCommentIds: string[]): Promise<Record<string, number>> {
|
||||
if (!parentCommentIds.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rows = await this.commentModel
|
||||
.aggregate<{ _id: Types.ObjectId; count: number }>([
|
||||
{
|
||||
$match: this.withActiveFilter({
|
||||
parentCommentId: { $in: parentCommentIds.map((id) => new Types.ObjectId(id)) },
|
||||
}),
|
||||
},
|
||||
{ $group: { _id: '$parentCommentId', count: { $sum: 1 } } },
|
||||
])
|
||||
.exec();
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row._id.toString(), row.count]));
|
||||
}
|
||||
|
||||
async findReplyPreviewsByParentIds(
|
||||
parentCommentIds: string[],
|
||||
limitPerParent = 2,
|
||||
): Promise<Record<string, CommentDocument[]>> {
|
||||
if (!parentCommentIds.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const previews: Record<string, CommentDocument[]> = {};
|
||||
await Promise.all(
|
||||
parentCommentIds.map(async (parentCommentId) => {
|
||||
previews[parentCommentId] = await this.findMany(
|
||||
{ parentCommentId: new Types.ObjectId(parentCommentId) },
|
||||
0,
|
||||
limitPerParent,
|
||||
{ createdAt: 1 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return previews;
|
||||
}
|
||||
|
||||
async countLikesByCommentIds(commentIds: string[]): Promise<Record<string, number>> {
|
||||
if (!commentIds.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rows = await this.likeModel
|
||||
.aggregate<{ _id: Types.ObjectId; count: number }>([
|
||||
{
|
||||
$match: {
|
||||
targetType: 'comment',
|
||||
targetId: { $in: commentIds.map((id) => new Types.ObjectId(id)) },
|
||||
},
|
||||
},
|
||||
{ $group: { _id: '$targetId', count: { $sum: 1 } } },
|
||||
])
|
||||
.exec();
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row._id.toString(), row.count]));
|
||||
}
|
||||
|
||||
async findLikedCommentIds(userId: string, commentIds: string[]): Promise<string[]> {
|
||||
if (!commentIds.length || !Types.ObjectId.isValid(userId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.likeModel
|
||||
.find({
|
||||
userId: new Types.ObjectId(userId),
|
||||
targetType: 'comment',
|
||||
targetId: { $in: commentIds.map((id) => new Types.ObjectId(id)) },
|
||||
})
|
||||
.select({ targetId: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
return rows.map((row) => row.targetId.toString());
|
||||
}
|
||||
|
||||
async countAdmin(filter: FilterQuery<CommentDocument>): Promise<number> {
|
||||
return this.commentModel.countDocuments(this.withAdminFilter(filter)).exec();
|
||||
}
|
||||
|
||||
@@ -6,13 +6,35 @@ 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 { FollowsRepository } from '../follows/follows.repository';
|
||||
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 { CommentQueryDto, CommentSortBy } from './dto/comment-query.dto';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { CreateCommentReplyDto } from './dto/create-comment-reply.dto';
|
||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { CommentsRepository } from './comments.repository';
|
||||
import { CommentDocument } from './schemas/comment.schema';
|
||||
|
||||
type CommentAuthorSummary = {
|
||||
id: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
stageName?: string;
|
||||
isVerified?: boolean;
|
||||
};
|
||||
|
||||
type InstagramComment = Record<string, unknown> & {
|
||||
repliesCount: number;
|
||||
repliesPreview: InstagramComment[];
|
||||
likesCount: number;
|
||||
likedByMe: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
replyToUser: CommentAuthorSummary | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
@@ -25,6 +47,7 @@ export class CommentsService {
|
||||
private readonly feedVersionService: FeedVersionService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly followsRepository: FollowsRepository,
|
||||
) {}
|
||||
|
||||
async create(userId: string, dto: CreateCommentDto) {
|
||||
@@ -32,6 +55,7 @@ export class CommentsService {
|
||||
if (!post) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
await this.assertCanComment(userId, post);
|
||||
|
||||
let parentRecipientId = '';
|
||||
if (dto.parentCommentId) {
|
||||
@@ -43,6 +67,7 @@ export class CommentsService {
|
||||
}
|
||||
|
||||
const content = dto.content.trim();
|
||||
const hiddenByFilter = this.matchesCommentFilter(content, post.commentFilterKeywords ?? []);
|
||||
const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, content, userId);
|
||||
const comment = await this.commentsRepository.create({
|
||||
postId: dto.postId,
|
||||
@@ -50,6 +75,8 @@ export class CommentsService {
|
||||
content,
|
||||
mentionUsernames: mentionResolution.mentionUsernames,
|
||||
parentCommentId: dto.parentCommentId,
|
||||
hiddenByFilter,
|
||||
hiddenReason: hiddenByFilter ? 'keyword_filter' : '',
|
||||
});
|
||||
await this.syncCommentsCount(dto.postId);
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
@@ -72,6 +99,58 @@ export class CommentsService {
|
||||
return comment;
|
||||
}
|
||||
|
||||
async pin(userId: string, commentId: string) {
|
||||
const comment = await this.commentsRepository.findById(commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
const post = await this.postsRepository.findById(comment.postId.toString());
|
||||
if (!post) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
if (this.extractEntityId(post.authorId) !== userId) {
|
||||
throw new ForbiddenException('Only the post owner can pin comments');
|
||||
}
|
||||
const updated = await this.commentsRepository.setPinned(commentId, true);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async unpin(userId: string, commentId: string) {
|
||||
const comment = await this.commentsRepository.findById(commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
const post = await this.postsRepository.findById(comment.postId.toString());
|
||||
if (!post) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
if (this.extractEntityId(post.authorId) !== userId) {
|
||||
throw new ForbiddenException('Only the post owner can unpin comments');
|
||||
}
|
||||
const updated = await this.commentsRepository.setPinned(commentId, false);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async createReply(userId: string, parentCommentId: string, dto: CreateCommentReplyDto) {
|
||||
const parent = await this.commentsRepository.findById(parentCommentId);
|
||||
if (!parent) {
|
||||
throw new NotFoundException('Parent comment not found');
|
||||
}
|
||||
|
||||
return this.create(userId, {
|
||||
postId: parent.postId.toString(),
|
||||
content: dto.content,
|
||||
mentionUsernames: dto.mentionUsernames,
|
||||
parentCommentId,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(userId: string, commentId: string) {
|
||||
const comment = await this.commentsRepository.findById(commentId);
|
||||
if (!comment) {
|
||||
@@ -155,7 +234,7 @@ export class CommentsService {
|
||||
return { success: true, message: 'Comment deleted by superadmin' };
|
||||
}
|
||||
|
||||
async findByPost(postId: string, query: CommentQueryDto) {
|
||||
async findByPost(viewerId: string, postId: string, query: CommentQueryDto) {
|
||||
if (!Types.ObjectId.isValid(postId)) {
|
||||
throw new BadRequestException('Invalid post id');
|
||||
}
|
||||
@@ -164,25 +243,28 @@ export class CommentsService {
|
||||
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 sort = this.resolveCommentSort(query);
|
||||
const filter = {
|
||||
postId: postObjectId,
|
||||
$or: [{ parentCommentId: { $exists: false } }, { parentCommentId: null }],
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
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 }],
|
||||
}),
|
||||
query.sortBy === CommentSortBy.TOP
|
||||
? this.commentsRepository.findManyTop(
|
||||
filter,
|
||||
skip,
|
||||
limit,
|
||||
resolveMongoSortDirection(query.sortOrder),
|
||||
)
|
||||
: this.commentsRepository.findMany(filter, skip, limit, sort),
|
||||
this.commentsRepository.count(filter),
|
||||
]);
|
||||
const enrichedItems = await this.enrichComments(items, viewerId, {
|
||||
includeRepliesPreview: true,
|
||||
});
|
||||
|
||||
return buildPaginatedResponse(items, {
|
||||
return buildPaginatedResponse(enrichedItems, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
@@ -190,7 +272,7 @@ export class CommentsService {
|
||||
});
|
||||
}
|
||||
|
||||
async findReplies(parentCommentId: string, query: CommentQueryDto) {
|
||||
async findReplies(viewerId: string, parentCommentId: string, query: CommentQueryDto) {
|
||||
if (!Types.ObjectId.isValid(parentCommentId)) {
|
||||
throw new BadRequestException('Invalid parent comment id');
|
||||
}
|
||||
@@ -199,14 +281,26 @@ export class CommentsService {
|
||||
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 sort = this.resolveCommentSort(query);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.commentsRepository.findMany({ parentCommentId: parentObjectId }, skip, limit, sort),
|
||||
const filter = { parentCommentId: parentObjectId };
|
||||
const [items, total, parent] = await Promise.all([
|
||||
query.sortBy === CommentSortBy.TOP
|
||||
? this.commentsRepository.findManyTop(
|
||||
filter,
|
||||
skip,
|
||||
limit,
|
||||
resolveMongoSortDirection(query.sortOrder),
|
||||
)
|
||||
: this.commentsRepository.findMany(filter, skip, limit, sort),
|
||||
this.commentsRepository.count({ parentCommentId: parentObjectId }),
|
||||
this.commentsRepository.findByIdWithAuthor(parentCommentId),
|
||||
]);
|
||||
const enrichedItems = await this.enrichComments(items, viewerId, {
|
||||
replyToUser: this.extractAuthorSummary(parent?.authorId),
|
||||
});
|
||||
|
||||
return buildPaginatedResponse(items, {
|
||||
return buildPaginatedResponse(enrichedItems, {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
@@ -286,6 +380,156 @@ export class CommentsService {
|
||||
await this.postsRepository.setCommentsCount(postId, totalComments);
|
||||
}
|
||||
|
||||
private resolveCommentSort(query: CommentQueryDto): Record<string, 1 | -1> {
|
||||
return { isPinned: -1, createdAt: resolveMongoSortDirection(query.sortOrder) };
|
||||
}
|
||||
|
||||
private async assertCanComment(userId: string, post: any): Promise<void> {
|
||||
if (post.commentsDisabled) {
|
||||
throw new ForbiddenException('Comments are disabled for this post');
|
||||
}
|
||||
|
||||
const authorId = this.extractEntityId(post.authorId);
|
||||
if (!post.commentsFollowersOnly || authorId === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const followsAuthor = await this.followsRepository.findOne(userId, authorId);
|
||||
if (!followsAuthor) {
|
||||
throw new ForbiddenException('Only followers can comment on this post');
|
||||
}
|
||||
}
|
||||
|
||||
private matchesCommentFilter(content: string, keywords: string[] = []): boolean {
|
||||
const normalized = content.toLowerCase();
|
||||
return keywords
|
||||
.map((keyword) => keyword.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.some((keyword) => normalized.includes(keyword));
|
||||
}
|
||||
|
||||
private async enrichComments(
|
||||
comments: CommentDocument[],
|
||||
viewerId: string,
|
||||
options: {
|
||||
includeRepliesPreview?: boolean;
|
||||
replyToUser?: CommentAuthorSummary | null;
|
||||
} = {},
|
||||
): Promise<InstagramComment[]> {
|
||||
if (!comments.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const commentIds = comments.map((comment) => comment.id);
|
||||
const [repliesCountByCommentId, likesCountByCommentId, likedCommentIds, previewsByCommentId] =
|
||||
await Promise.all([
|
||||
this.commentsRepository.countRepliesByParentIds(commentIds),
|
||||
this.commentsRepository.countLikesByCommentIds(commentIds),
|
||||
this.commentsRepository.findLikedCommentIds(viewerId, commentIds),
|
||||
options.includeRepliesPreview
|
||||
? this.commentsRepository.findReplyPreviewsByParentIds(commentIds, 2)
|
||||
: Promise.resolve({} as Record<string, CommentDocument[]>),
|
||||
]);
|
||||
|
||||
const likedCommentIdSet = new Set(likedCommentIds);
|
||||
const previewCommentIds = Object.values(previewsByCommentId)
|
||||
.flat()
|
||||
.map((comment) => comment.id);
|
||||
const [previewLikesCountByCommentId, previewLikedCommentIds] = await Promise.all([
|
||||
this.commentsRepository.countLikesByCommentIds(previewCommentIds),
|
||||
this.commentsRepository.findLikedCommentIds(viewerId, previewCommentIds),
|
||||
]);
|
||||
const previewLikedCommentIdSet = new Set(previewLikedCommentIds);
|
||||
|
||||
return comments.map((comment) => {
|
||||
const commentId = comment.id;
|
||||
const repliesPreview = (previewsByCommentId[commentId] ?? []).map((reply) =>
|
||||
this.serializeComment(reply, {
|
||||
viewerId,
|
||||
likesCount: previewLikesCountByCommentId[reply.id] ?? 0,
|
||||
likedByMe: previewLikedCommentIdSet.has(reply.id),
|
||||
repliesCount: 0,
|
||||
repliesPreview: [],
|
||||
replyToUser: this.extractAuthorSummary(comment.authorId),
|
||||
}),
|
||||
);
|
||||
|
||||
return this.serializeComment(comment, {
|
||||
viewerId,
|
||||
likesCount: likesCountByCommentId[commentId] ?? 0,
|
||||
likedByMe: likedCommentIdSet.has(commentId),
|
||||
repliesCount: repliesCountByCommentId[commentId] ?? 0,
|
||||
repliesPreview,
|
||||
replyToUser: options.replyToUser ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private serializeComment(
|
||||
comment: CommentDocument,
|
||||
params: {
|
||||
viewerId: string;
|
||||
likesCount: number;
|
||||
likedByMe: boolean;
|
||||
repliesCount: number;
|
||||
repliesPreview: InstagramComment[];
|
||||
replyToUser: CommentAuthorSummary | null;
|
||||
},
|
||||
): InstagramComment {
|
||||
const plain: Record<string, unknown> =
|
||||
typeof comment.toObject === 'function'
|
||||
? (comment.toObject({ virtuals: true }) as Record<string, unknown>)
|
||||
: (comment as unknown as Record<string, unknown>);
|
||||
const authorId = this.extractEntityId(plain.authorId ?? comment.authorId);
|
||||
const isDeleted = Boolean(plain.isDeleted);
|
||||
|
||||
return {
|
||||
...plain,
|
||||
id: plain.id ?? comment.id,
|
||||
content: isDeleted ? 'This comment was deleted' : plain.content,
|
||||
repliesCount: params.repliesCount,
|
||||
repliesPreview: params.repliesPreview,
|
||||
likesCount: params.likesCount,
|
||||
likedByMe: params.likedByMe,
|
||||
canEdit: !isDeleted && authorId === params.viewerId,
|
||||
canDelete: !isDeleted && authorId === params.viewerId,
|
||||
replyToUser: params.replyToUser,
|
||||
};
|
||||
}
|
||||
|
||||
private extractAuthorSummary(value: unknown): CommentAuthorSummary | null {
|
||||
if (!value || value instanceof Types.ObjectId || typeof value === 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as {
|
||||
_id?: unknown;
|
||||
id?: unknown;
|
||||
name?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
stageName?: string;
|
||||
isVerified?: boolean;
|
||||
};
|
||||
const id = this.extractEntityId(candidate);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: candidate.name,
|
||||
username: candidate.username,
|
||||
avatar: candidate.avatar,
|
||||
stageName: candidate.stageName,
|
||||
isVerified: candidate.isVerified,
|
||||
};
|
||||
}
|
||||
|
||||
private async dispatchCommentNotifications(
|
||||
actorId: string,
|
||||
postAuthorId: string,
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export enum CommentSortBy {
|
||||
CREATED_AT = 'createdAt',
|
||||
TOP = 'top',
|
||||
}
|
||||
|
||||
export class CommentQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: CommentSortBy,
|
||||
default: CommentSortBy.CREATED_AT,
|
||||
description: 'Use top for Instagram-like popular comments, or createdAt for chronological sorting',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
)
|
||||
@IsEnum(CommentSortBy)
|
||||
sortBy?: CommentSortBy = CommentSortBy.CREATED_AT;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Use asc to display oldest comments first, or desc for newest first',
|
||||
default: 'desc',
|
||||
|
||||
7
src/modules/comments/dto/create-comment-reply.dto.ts
Normal file
7
src/modules/comments/dto/create-comment-reply.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PickType } from '@nestjs/swagger';
|
||||
import { CreateCommentDto } from './create-comment.dto';
|
||||
|
||||
export class CreateCommentReplyDto extends PickType(CreateCommentDto, [
|
||||
'content',
|
||||
'mentionUsernames',
|
||||
] as const) {}
|
||||
@@ -37,6 +37,15 @@ export class Comment {
|
||||
@Prop({ default: false, index: true })
|
||||
isDeleted!: boolean;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
isPinned!: boolean;
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
hiddenByFilter!: boolean;
|
||||
|
||||
@Prop({ default: '', maxlength: 120 })
|
||||
hiddenReason!: string;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
deletedAt?: Date | null;
|
||||
|
||||
@@ -48,3 +57,4 @@ 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 });
|
||||
CommentSchema.index({ postId: 1, isPinned: -1, createdAt: -1 });
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم