Add Instagram-style social features and Postman collections

هذا الالتزام موجود في:
boutmoun123
2026-05-24 15:21:03 +03:00
الأصل fdc40192f7
التزام 367fce6557
56 ملفات معدلة مع 20266 إضافات و5965 حذوفات

عرض الملف

@@ -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',

عرض الملف

@@ -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 });