Add Instagram-style social features and Postman collections

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

عرض الملف

@@ -13,6 +13,7 @@ import { RedisModule } from './infrastructure/redis/redis.module';
import { StorageModule } from './infrastructure/storage/storage.module';
import { AuthModule } from './modules/auth/auth.module';
import { AuditModule } from './modules/audit/audit.module';
import { BlocksModule } from './modules/blocks/blocks.module';
import { ChatModule } from './modules/chat/chat.module';
import { CommentsModule } from './modules/comments/comments.module';
import { FeedModule } from './modules/feed/feed.module';
@@ -23,6 +24,7 @@ import { MarketplaceModule } from './modules/marketplace/marketplace.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { OutboxModule } from './modules/outbox/outbox.module';
import { PostsModule } from './modules/posts/posts.module';
import { ReportsModule } from './modules/reports/reports.module';
import { SavesModule } from './modules/saves/saves.module';
import { SuperAdminModule } from './modules/superadmin/superadmin.module';
import { UsersModule } from './modules/users/users.module';
@@ -43,6 +45,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
QueueModule,
DatabaseModule,
AuditModule,
BlocksModule,
UsersModule,
AuthModule,
PostsModule,
@@ -55,6 +58,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
ChatModule,
MediaModule,
MarketplaceModule,
ReportsModule,
SavesModule,
SuperAdminModule,
],

عرض الملف

@@ -0,0 +1,8 @@
export enum ReactionType {
LIKE = 'like',
LOVE = 'love',
HAHA = 'haha',
WOW = 'wow',
SAD = 'sad',
ANGRY = 'angry',
}

عرض الملف

@@ -0,0 +1,11 @@
export enum ReportReason {
SPAM = 'spam',
HARASSMENT = 'harassment',
HATE_SPEECH = 'hate_speech',
NUDITY = 'nudity',
VIOLENCE = 'violence',
SCAM = 'scam',
INTELLECTUAL_PROPERTY = 'intellectual_property',
SELF_HARM = 'self_harm',
OTHER = 'other',
}

عرض الملف

@@ -0,0 +1,29 @@
import { Controller, Get, Param, Patch, Post, 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 { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { BlocksService } from './blocks.service';
@ApiTags('Blocks')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('blocks')
export class BlocksController {
constructor(private readonly blocksService: BlocksService) {}
@Post(':targetUserId')
async block(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
return this.blocksService.block(user.sub, targetUserId);
}
@Patch(':targetUserId/unblock')
async unblock(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
return this.blocksService.unblock(user.sub, targetUserId);
}
@Get('status/:targetUserId')
async status(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
return this.blocksService.getStatus(user.sub, targetUserId);
}
}

عرض الملف

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { UsersModule } from '../users/users.module';
import { BlocksController } from './blocks.controller';
import { BlocksRepository } from './blocks.repository';
import { BlocksService } from './blocks.service';
import { Block, BlockSchema } from './schemas/block.schema';
@Module({
imports: [UsersModule, MongooseModule.forFeature([{ name: Block.name, schema: BlockSchema }])],
controllers: [BlocksController],
providers: [BlocksService, BlocksRepository],
exports: [BlocksService, BlocksRepository],
})
export class BlocksModule {}

عرض الملف

@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { Block, BlockDocument } from './schemas/block.schema';
@Injectable()
export class BlocksRepository {
constructor(@InjectModel(Block.name) private readonly blockModel: Model<BlockDocument>) {}
async findOne(blockerId: string, blockedId: string): Promise<BlockDocument | null> {
return this.blockModel
.findOne({
blockerId: new Types.ObjectId(blockerId),
blockedId: new Types.ObjectId(blockedId),
})
.exec();
}
async findAnyBetween(userA: string, userB: string): Promise<BlockDocument | null> {
return this.blockModel
.findOne({
$or: [
{ blockerId: new Types.ObjectId(userA), blockedId: new Types.ObjectId(userB) },
{ blockerId: new Types.ObjectId(userB), blockedId: new Types.ObjectId(userA) },
],
})
.exec();
}
async create(blockerId: string, blockedId: string): Promise<void> {
await this.blockModel
.updateOne(
{
blockerId: new Types.ObjectId(blockerId),
blockedId: new Types.ObjectId(blockedId),
},
{
$setOnInsert: {
blockerId: new Types.ObjectId(blockerId),
blockedId: new Types.ObjectId(blockedId),
},
},
{ upsert: true },
)
.exec();
}
async remove(blockerId: string, blockedId: string): Promise<void> {
await this.blockModel
.deleteOne({
blockerId: new Types.ObjectId(blockerId),
blockedId: new Types.ObjectId(blockedId),
})
.exec();
}
async findBlockedIds(blockerId: string): Promise<string[]> {
const rows = await this.blockModel
.find({ blockerId: new Types.ObjectId(blockerId) })
.select({ blockedId: 1 })
.lean()
.exec();
return rows.map((row) => row.blockedId.toString());
}
async findBlockingOrBlockedIds(userId: string): Promise<string[]> {
const userObjectId = new Types.ObjectId(userId);
const rows = await this.blockModel
.find({ $or: [{ blockerId: userObjectId }, { blockedId: userObjectId }] })
.select({ blockerId: 1, blockedId: 1 })
.lean()
.exec();
return Array.from(
new Set(
rows.map((row) =>
row.blockerId.toString() === userId ? row.blockedId.toString() : row.blockerId.toString(),
),
),
);
}
}

عرض الملف

@@ -0,0 +1,64 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { UsersRepository } from '../users/users.repository';
import { BlocksRepository } from './blocks.repository';
@Injectable()
export class BlocksService {
constructor(
private readonly blocksRepository: BlocksRepository,
private readonly usersRepository: UsersRepository,
) {}
async block(currentUserId: string, targetUserId: string) {
this.assertValidTarget(currentUserId, targetUserId);
const target = await this.usersRepository.findById(targetUserId);
if (!target || target.isDisabled) {
throw new NotFoundException('Target user not found');
}
await this.blocksRepository.create(currentUserId, targetUserId);
return { blocked: true, targetUserId };
}
async unblock(currentUserId: string, targetUserId: string) {
this.assertValidTarget(currentUserId, targetUserId);
await this.blocksRepository.remove(currentUserId, targetUserId);
return { blocked: false, targetUserId };
}
async getStatus(currentUserId: string, targetUserId: string) {
this.assertValidTarget(currentUserId, targetUserId);
const [iBlocked, blockedMe] = await Promise.all([
this.blocksRepository.findOne(currentUserId, targetUserId),
this.blocksRepository.findOne(targetUserId, currentUserId),
]);
return { targetUserId, iBlocked: !!iBlocked, blockedMe: !!blockedMe };
}
async getBlockedIds(currentUserId: string): Promise<string[]> {
return this.blocksRepository.findBlockedIds(currentUserId);
}
async getInvisibleUserIds(currentUserId: string): Promise<string[]> {
return this.blocksRepository.findBlockingOrBlockedIds(currentUserId);
}
async assertNoBlockBetween(currentUserId: string, targetUserId: string): Promise<void> {
this.assertValidTarget(currentUserId, targetUserId);
const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
if (block) {
throw new NotFoundException('User not found');
}
}
private assertValidTarget(currentUserId: string, targetUserId: string): void {
if (!Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid target user id');
}
if (currentUserId === targetUserId) {
throw new BadRequestException('You cannot block yourself');
}
}
}

عرض الملف

@@ -0,0 +1,17 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { User } from '../../users/schemas/user.schema';
export type BlockDocument = HydratedDocument<Block>;
@Schema({ timestamps: true, versionKey: false })
export class Block {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
blockerId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
blockedId!: Types.ObjectId;
}
export const BlockSchema = SchemaFactory.createForClass(Block);
BlockSchema.index({ blockerId: 1, blockedId: 1 }, { unique: true });

عرض الملف

@@ -1,11 +1,24 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Throttle } from '../../common/decorators/throttle.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { ChatService } from './chat.service';
import { CreateConversationDto } from './dto/create-conversation.dto';
import { MessageReactionDto } from './dto/message-reaction.dto';
import { MessageQueryDto } from './dto/message-query.dto';
import { SendMessageDto } from './dto/send-message.dto';
@@ -42,6 +55,32 @@ export class ChatController {
return this.chatService.sendMessage(user.sub, dto);
}
@Post('messages/upload')
@Throttle(60, 60_000)
@UseInterceptors(FileFieldsInterceptor([{ name: 'mediaFile', maxCount: 1 }]))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
conversationId: { type: 'string' },
content: { type: 'string' },
replyToMessageId: { type: 'string' },
mediaFile: { type: 'string', format: 'binary' },
},
},
})
async sendMessageUpload(
@CurrentUser() user: JwtPayload,
@Body() dto: SendMessageDto,
@UploadedFiles()
files?: {
mediaFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>;
},
) {
return this.chatService.sendMessageWithUpload(user.sub, dto, files?.mediaFile?.[0]);
}
@Patch('messages/:messageId/seen')
@Throttle(200, 60_000)
async markSeen(@CurrentUser() user: JwtPayload, @Param('messageId') messageId: string) {
@@ -54,6 +93,22 @@ export class ChatController {
return this.chatService.unsendMessage(user.sub, messageId);
}
@Patch('messages/:messageId/reaction')
@Throttle(120, 60_000)
async react(
@CurrentUser() user: JwtPayload,
@Param('messageId') messageId: string,
@Body() dto: MessageReactionDto,
) {
return this.chatService.reactToMessage(user.sub, messageId, dto.reactionType);
}
@Patch('messages/:messageId/delete-for-me')
@Throttle(120, 60_000)
async deleteForMe(@CurrentUser() user: JwtPayload, @Param('messageId') messageId: string) {
return this.chatService.deleteMessageForMe(user.sub, messageId);
}
@Post('blocks/:targetUserId')
@Throttle(20, 60_000)
async blockUser(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {

عرض الملف

@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { MongooseModule } from '@nestjs/mongoose';
import { NotificationsModule } from '../notifications/notifications.module';
import { StorageModule } from '../../infrastructure/storage/storage.module';
import { UsersModule } from '../users/users.module';
import { ChatController } from './chat.controller';
import { ChatGateway } from './chat.gateway';
@@ -17,6 +18,7 @@ import { Message, MessageSchema } from './schemas/message.schema';
ConfigModule,
JwtModule.register({}),
NotificationsModule,
StorageModule,
UsersModule,
MongooseModule.forFeature([
{ name: Conversation.name, schema: ConversationSchema },

عرض الملف

@@ -76,6 +76,7 @@ export class ChatRepository {
content?: string;
messageType: 'text' | 'image' | 'video' | 'audio';
mediaUrl?: string;
replyToMessageId?: string | null;
}): Promise<MessageDocument> {
return this.messageModel.create({
conversationId: new Types.ObjectId(payload.conversationId),
@@ -83,6 +84,7 @@ export class ChatRepository {
content: payload.content ?? '',
messageType: payload.messageType,
mediaUrl: payload.mediaUrl ?? '',
replyToMessageId: payload.replyToMessageId ? new Types.ObjectId(payload.replyToMessageId) : null,
seenBy: [new Types.ObjectId(payload.senderId)],
isUnsent: false,
});
@@ -90,21 +92,35 @@ export class ChatRepository {
async findMessages(
conversationId: string,
userId: string,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<MessageDocument[]> {
return this.messageModel
.find({ conversationId: new Types.ObjectId(conversationId) })
.find({
conversationId: new Types.ObjectId(conversationId),
deletedForUserIds: { $ne: new Types.ObjectId(userId) },
})
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
.populate({
path: 'replyToMessageId',
select: 'content messageType mediaUrl senderId isUnsent',
populate: { path: 'senderId', select: 'name username stageName avatar isVerified' },
})
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
}
async countMessages(conversationId: string): Promise<number> {
return this.messageModel.countDocuments({ conversationId: new Types.ObjectId(conversationId) }).exec();
async countMessages(conversationId: string, userId: string): Promise<number> {
return this.messageModel
.countDocuments({
conversationId: new Types.ObjectId(conversationId),
deletedForUserIds: { $ne: new Types.ObjectId(userId) },
})
.exec();
}
async findMessageById(messageId: string): Promise<MessageDocument | null> {
@@ -117,6 +133,27 @@ export class ChatRepository {
.exec();
}
async setMessageReaction(
messageId: string,
userId: string,
reactionType: string,
): Promise<MessageDocument | null> {
return this.messageModel
.findByIdAndUpdate(
messageId,
{ $set: { [`reactionsByUser.${userId}`]: reactionType } },
{ new: true },
)
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
.exec();
}
async deleteMessageForUser(messageId: string, userId: string): Promise<void> {
await this.messageModel
.findByIdAndUpdate(messageId, { $addToSet: { deletedForUserIds: new Types.ObjectId(userId) } })
.exec();
}
async updateConversationAfterNewMessage(
conversationId: string,
messageId: string,

عرض الملف

@@ -1,8 +1,11 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { extname } from 'path';
import { ReactionType } from '../../common/enums/reaction-type.enum';
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
import { NotificationsService } from '../notifications/notifications.service';
import { UsersRepository } from '../users/users.repository';
import { CreateConversationDto } from './dto/create-conversation.dto';
@@ -18,6 +21,7 @@ export class ChatService {
private readonly chatRepository: ChatRepository,
private readonly usersRepository: UsersRepository,
private readonly notificationsService: NotificationsService,
private readonly storageService: ManagedStorageService,
) {}
async createConversation(currentUserId: string, dto: CreateConversationDto) {
@@ -108,8 +112,8 @@ export class ChatService {
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.chatRepository.findMessages(conversation.id, skip, limit, sort),
this.chatRepository.countMessages(conversation.id),
this.chatRepository.findMessages(conversation.id, currentUserId, skip, limit, sort),
this.chatRepository.countMessages(conversation.id, currentUserId),
]);
await this.chatRepository.clearConversationUnreadForUser(conversation.id, currentUserId);
@@ -142,12 +146,14 @@ export class ChatService {
throw new BadRequestException('mediaUrl is required for non-text messages');
}
const replyToMessageId = await this.resolveReplyToMessageId(dto.replyToMessageId, conversation.id);
const message = await this.chatRepository.createMessage({
conversationId: conversation.id,
senderId: currentUserId,
content,
messageType,
mediaUrl,
replyToMessageId,
});
const preview = messageType === 'text' ? content : `${messageType} message`;
@@ -167,6 +173,36 @@ export class ChatService {
return message;
}
async sendMessageWithUpload(
currentUserId: string,
dto: SendMessageDto,
file?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
) {
if (!file) {
throw new BadRequestException('mediaFile is required');
}
const mediaType = this.resolveUploadedMessageType(file);
const mediaUrl = await this.storageService.saveFile({
folderSegments: ['chat', 'media'],
extension: this.resolveMediaExtension(mediaType, file),
buffer: file.buffer,
contentType: file.mimetype,
fileNamePrefix: 'message',
});
try {
return await this.sendMessage(currentUserId, {
...dto,
messageType: mediaType,
mediaUrl,
});
} catch (error) {
await this.storageService.deleteFile(mediaUrl);
throw error;
}
}
async markMessageSeen(currentUserId: string, messageId: string) {
const message = await this.chatRepository.findMessageById(messageId);
if (!message) {
@@ -196,6 +232,29 @@ export class ChatService {
return updated;
}
async reactToMessage(currentUserId: string, messageId: string, reactionType: ReactionType) {
const message = await this.chatRepository.findMessageById(messageId);
if (!message) {
throw new NotFoundException('Message not found');
}
await this.assertConversationMember(currentUserId, message.conversationId.toString());
const updated = await this.chatRepository.setMessageReaction(messageId, currentUserId, reactionType);
if (!updated) {
throw new NotFoundException('Message not found');
}
return updated;
}
async deleteMessageForMe(currentUserId: string, messageId: string) {
const message = await this.chatRepository.findMessageById(messageId);
if (!message) {
throw new NotFoundException('Message not found');
}
await this.assertConversationMember(currentUserId, message.conversationId.toString());
await this.chatRepository.deleteMessageForUser(messageId, currentUserId);
return { success: true };
}
async blockUser(currentUserId: string, targetUserId: string) {
if (!Types.ObjectId.isValid(targetUserId)) {
throw new BadRequestException('Invalid target user id');
@@ -267,6 +326,65 @@ export class ChatService {
}
}
private async resolveReplyToMessageId(replyToMessageId: string | undefined, conversationId: string) {
if (!replyToMessageId) {
return null;
}
if (!Types.ObjectId.isValid(replyToMessageId)) {
throw new BadRequestException('Invalid replyToMessageId');
}
const replyMessage = await this.chatRepository.findMessageById(replyToMessageId);
if (!replyMessage || replyMessage.conversationId.toString() !== conversationId) {
throw new BadRequestException('Reply message must belong to the same conversation');
}
return replyMessage.id;
}
private resolveUploadedMessageType(file: { mimetype?: string; originalname?: string }) {
if (file.mimetype?.startsWith('image/')) {
return 'image' as const;
}
if (file.mimetype?.startsWith('video/')) {
return 'video' as const;
}
if (file.mimetype?.startsWith('audio/')) {
return 'audio' as const;
}
const extension = extname(file.originalname ?? '').toLowerCase();
if (['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(extension)) {
return 'image' as const;
}
if (['.mp4', '.mov', '.webm', '.mkv', '.avi'].includes(extension)) {
return 'video' as const;
}
if (['.mp3', '.wav', '.m4a', '.aac', '.ogg'].includes(extension)) {
return 'audio' as const;
}
throw new BadRequestException('mediaFile must be image, video, or audio');
}
private resolveMediaExtension(
mediaType: 'image' | 'video' | 'audio',
file: { mimetype?: string; originalname?: string },
): string {
const extension = extname(file.originalname ?? '').toLowerCase();
const allowed = {
image: new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']),
video: new Set(['.mp4', '.mov', '.webm', '.mkv', '.avi']),
audio: new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg', '.webm']),
}[mediaType];
if (allowed.has(extension)) {
return extension;
}
if (mediaType === 'image') {
return file.mimetype === 'image/png' ? '.png' : file.mimetype === 'image/webp' ? '.webp' : '.jpg';
}
if (mediaType === 'video') {
return file.mimetype === 'video/quicktime' ? '.mov' : file.mimetype === 'video/webm' ? '.webm' : '.mp4';
}
return file.mimetype === 'audio/webm' ? '.webm' : file.mimetype === 'audio/ogg' ? '.ogg' : '.mp3';
}
private async dispatchMessageNotifications(
actorId: string,
participantIds: string[],

عرض الملف

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum } from 'class-validator';
import { ReactionType } from '../../../common/enums/reaction-type.enum';
export class MessageReactionDto {
@ApiProperty({ enum: ReactionType })
@IsEnum(ReactionType)
reactionType!: ReactionType;
}

عرض الملف

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator';
import { IsEnum, IsMongoId, IsOptional, IsString, IsUrl, Length } from 'class-validator';
export class SendMessageDto {
@IsString()
@@ -20,4 +20,9 @@ export class SendMessageDto {
@IsOptional()
@IsUrl({ require_tld: false })
mediaUrl?: string;
@ApiPropertyOptional()
@IsOptional()
@IsMongoId()
replyToMessageId?: string;
}

عرض الملف

@@ -29,6 +29,7 @@ export class Conversation {
@Prop({ type: Map, of: Number, default: {} })
unreadCountByUser!: Map<string, number>;
}
export const ConversationSchema = SchemaFactory.createForClass(Conversation);

عرض الملف

@@ -22,11 +22,21 @@ export class Message {
@Prop({ required: false, default: '' })
mediaUrl!: string;
@Prop({ type: Types.ObjectId, ref: 'Message', default: null, index: true })
replyToMessageId?: Types.ObjectId | null;
@Prop({ type: Map, of: String, default: {} })
reactionsByUser!: Map<string, string>;
@Prop({ type: [Types.ObjectId], ref: User.name, default: [] })
seenBy!: Types.ObjectId[];
@Prop({ type: [Types.ObjectId], ref: User.name, default: [], index: true })
deletedForUserIds!: Types.ObjectId[];
@Prop({ default: false, index: true })
isUnsent!: boolean;
}
export const MessageSchema = SchemaFactory.createForClass(Message);

عرض الملف

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

عرض الملف

@@ -24,4 +24,11 @@ export class FeedController {
async trending(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) {
return this.feedService.getTrending(user.sub, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('explore')
async explore(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) {
return this.feedService.getExplore(user.sub, query);
}
}

عرض الملف

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { BlocksModule } from '../blocks/blocks.module';
import { FollowsModule } from '../follows/follows.module';
import { LikesModule } from '../likes/likes.module';
import { MarketplaceModule } from '../marketplace/marketplace.module';
@@ -14,6 +15,7 @@ import { FeedRepository } from './feed.repository';
@Module({
imports: [
UsersModule,
BlocksModule,
LikesModule,
SavesModule,
FollowsModule,

عرض الملف

@@ -28,6 +28,7 @@ export class FeedRepository {
const activeFilter: FilterQuery<PostDocument> = {
...filter,
isDeleted: { $ne: true },
isArchived: { $ne: true },
};
return this.postModel
@@ -48,7 +49,7 @@ export class FeedRepository {
limit: number,
): Promise<PostDocument[]> {
return this.postModel
.find({ ...filter, isDeleted: { $ne: true } })
.find({ ...filter, isDeleted: { $ne: true }, isArchived: { $ne: true } })
.populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' })
.sort({
shareCount: -1,
@@ -65,6 +66,8 @@ export class FeedRepository {
}
async count(filter: FilterQuery<PostDocument>): Promise<number> {
return this.postModel.countDocuments({ ...filter, isDeleted: { $ne: true } }).exec();
return this.postModel
.countDocuments({ ...filter, isDeleted: { $ne: true }, isArchived: { $ne: true } })
.exec();
}
}

عرض الملف

@@ -7,6 +7,7 @@ import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/curso
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { AppCacheService } from '../../infrastructure/cache/app-cache.service';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { BlocksService } from '../blocks/blocks.service';
import { FollowsService } from '../follows/follows.service';
import { LikesRepository } from '../likes/likes.repository';
import { MarketplaceService } from '../marketplace/marketplace.service';
@@ -66,6 +67,7 @@ export class FeedService {
private readonly savesRepository: SavesRepository,
private readonly followsService: FollowsService,
private readonly marketplaceService: MarketplaceService,
private readonly blocksService: BlocksService,
) {}
async getMyFeed(currentUserId: string, query: FeedQueryDto) {
@@ -104,8 +106,11 @@ export class FeedService {
const radiusKm = query.radiusKm ?? 30;
const skip = cursorOffset ?? (page - 1) * limit;
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly);
const [followingIds, invisibleUserIds] = await Promise.all([
this.feedRepository.findFollowingIds(currentUserId),
this.blocksService.getInvisibleUserIds(currentUserId),
]);
let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly, invisibleUserIds);
let candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
// Keep the default home feed focused on followed accounts, but avoid an empty screen
@@ -115,7 +120,7 @@ export class FeedService {
typeof query.followingOnly === 'undefined' &&
followingOnly
) {
filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false);
filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false, invisibleUserIds);
candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
}
@@ -177,6 +182,14 @@ export class FeedService {
return result;
}
async getExplore(currentUserId: string, query: FeedQueryDto) {
return this.getTrending(currentUserId, {
...query,
followingOnly: false,
includeSuggestions: false,
});
}
async getTrending(currentUserId: string, query: FeedQueryDto) {
const cacheEnabled =
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
@@ -200,8 +213,14 @@ export class FeedService {
const cursorOffset = decodeOffsetCursor(query.cursor);
const page = query.page ?? 1;
const skip = cursorOffset ?? (page - 1) * limit;
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
const [followingIds, invisibleUserIds] = await Promise.all([
this.feedRepository.findFollowingIds(currentUserId),
this.blocksService.getInvisibleUserIds(currentUserId),
]);
const trendingFilter: Record<string, unknown> = { visibility: PostVisibility.PUBLIC };
if (invisibleUserIds.length) {
trendingFilter.authorId = { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) };
}
if (query.preferredPostType) {
trendingFilter.postType = query.preferredPostType;
}
@@ -377,12 +396,13 @@ export class FeedService {
currentUserId: string,
followingIds: string[],
followingOnly: boolean,
invisibleUserIds: string[] = [],
): Record<string, unknown> {
const currentUserObjectId = new Types.ObjectId(currentUserId);
const followingObjectIds = followingIds.map((id) => new Types.ObjectId(id));
if (followingOnly) {
return {
const visibilityFilter = followingOnly
? {
$or: [
{ authorId: currentUserObjectId },
{
@@ -390,10 +410,8 @@ export class FeedService {
visibility: { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] },
},
],
};
}
return {
}
: {
$or: [
{ visibility: PostVisibility.PUBLIC },
{ authorId: currentUserObjectId },
@@ -403,6 +421,17 @@ export class FeedService {
},
],
};
if (!invisibleUserIds.length) {
return visibilityFilter;
}
return {
$and: [
visibilityFilter,
{ authorId: { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) } },
],
};
}
private scorePost(input: {

عرض الملف

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Throttle } from '../../common/decorators/throttle.decorator';
@@ -49,4 +49,25 @@ export class FollowsController {
async suggestions(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
return this.followsService.getSuggestions(user.sub, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('requests')
async requests(@CurrentUser() user: JwtPayload, @Query() query: PaginationQueryDto) {
return this.followsService.getPendingRequests(user.sub, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch('requests/:requestId/approve')
async approveRequest(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
return this.followsService.approveRequest(user.sub, requestId);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch('requests/:requestId/reject')
async rejectRequest(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) {
return this.followsService.rejectRequest(user.sub, requestId);
}
}

عرض الملف

@@ -5,6 +5,7 @@ import { UsersModule } from '../users/users.module';
import { FollowsController } from './follows.controller';
import { FollowsService } from './follows.service';
import { FollowsRepository } from './follows.repository';
import { FollowRequest, FollowRequestSchema } from './schemas/follow-request.schema';
import { Follow, FollowSchema } from './schemas/follow.schema';
@Module({
@@ -16,10 +17,14 @@ import { Follow, FollowSchema } from './schemas/follow.schema';
name: Follow.name,
schema: FollowSchema,
},
{
name: FollowRequest.name,
schema: FollowRequestSchema,
},
]),
],
controllers: [FollowsController],
providers: [FollowsService, FollowsRepository],
exports: [FollowsService],
exports: [FollowsService, FollowsRepository],
})
export class FollowsModule {}

عرض الملف

@@ -1,11 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
import { FollowRequest, FollowRequestDocument } from './schemas/follow-request.schema';
import { Follow, FollowDocument } from './schemas/follow.schema';
@Injectable()
export class FollowsRepository {
constructor(@InjectModel(Follow.name) private readonly followModel: Model<FollowDocument>) {}
constructor(
@InjectModel(Follow.name) private readonly followModel: Model<FollowDocument>,
@InjectModel(FollowRequest.name)
private readonly followRequestModel: Model<FollowRequestDocument>,
) {}
async findOne(followerId: string, followingId: string): Promise<FollowDocument | null> {
return this.followModel
@@ -67,4 +72,66 @@ export class FollowsRepository {
return rows.map((row) => row.followingId.toString());
}
async findPendingRequest(requesterId: string, targetUserId: string): Promise<FollowRequestDocument | null> {
return this.followRequestModel
.findOne({
requesterId: new Types.ObjectId(requesterId),
targetUserId: new Types.ObjectId(targetUserId),
status: 'pending',
})
.exec();
}
async upsertPendingRequest(requesterId: string, targetUserId: string): Promise<FollowRequestDocument> {
return this.followRequestModel
.findOneAndUpdate(
{
requesterId: new Types.ObjectId(requesterId),
targetUserId: new Types.ObjectId(targetUserId),
},
{
requesterId: new Types.ObjectId(requesterId),
targetUserId: new Types.ObjectId(targetUserId),
status: 'pending',
},
{ new: true, upsert: true },
)
.exec();
}
async updateRequestStatus(
requestId: string,
targetUserId: string,
status: 'approved' | 'rejected',
): Promise<FollowRequestDocument | null> {
return this.followRequestModel
.findOneAndUpdate(
{ _id: new Types.ObjectId(requestId), targetUserId: new Types.ObjectId(targetUserId), status: 'pending' },
{ status },
{ new: true },
)
.exec();
}
async findPendingRequestsForTarget(
targetUserId: string,
skip: number,
limit: number,
sort: Record<string, 1 | -1> = { createdAt: -1 },
): Promise<FollowRequestDocument[]> {
return this.followRequestModel
.find({ targetUserId: new Types.ObjectId(targetUserId), status: 'pending' })
.populate({ path: 'requesterId', select: 'name username stageName avatar isVerified' })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
}
async countPendingRequestsForTarget(targetUserId: string): Promise<number> {
return this.followRequestModel
.countDocuments({ targetUserId: new Types.ObjectId(targetUserId), status: 'pending' })
.exec();
}
}

عرض الملف

@@ -46,6 +46,11 @@ export class FollowsService {
return { following: false };
}
if (targetUser.isPrivate) {
const request = await this.followsRepository.upsertPendingRequest(currentUserId, targetUserId);
return { following: false, requested: true, requestId: request.id };
}
const follow = await this.followsRepository.create(currentUserId, targetUserId);
await this.syncFollowCounts(currentUserId, targetUserId);
await this.feedVersionService.bumpGlobalVersion();
@@ -109,12 +114,64 @@ export class FollowsService {
}
const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
const pendingRequest =
currentUserId === targetUserId
? null
: await this.followsRepository.findPendingRequest(currentUserId, targetUserId);
return {
following: !!existing,
requested: !!pendingRequest,
targetUserId,
};
}
async getPendingRequests(currentUserId: string, query: PaginationQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.followsRepository.findPendingRequestsForTarget(currentUserId, skip, limit, sort),
this.followsRepository.countPendingRequestsForTarget(currentUserId),
]);
return buildPaginatedResponse(items, { page, limit, total, offset: skip });
}
async approveRequest(currentUserId: string, requestId: string) {
if (!Types.ObjectId.isValid(requestId)) {
throw new BadRequestException('Invalid follow request id');
}
const request = await this.followsRepository.updateRequestStatus(requestId, currentUserId, 'approved');
if (!request) {
throw new NotFoundException('Follow request not found');
}
const requesterId = request.requesterId.toString();
const existing = await this.followsRepository.findOne(requesterId, currentUserId);
if (!existing) {
await this.followsRepository.create(requesterId, currentUserId);
await this.syncFollowCounts(requesterId, currentUserId);
await this.feedVersionService.bumpGlobalVersion();
}
return { approved: true, following: true, requesterId };
}
async rejectRequest(currentUserId: string, requestId: string) {
if (!Types.ObjectId.isValid(requestId)) {
throw new BadRequestException('Invalid follow request id');
}
const request = await this.followsRepository.updateRequestStatus(requestId, currentUserId, 'rejected');
if (!request) {
throw new NotFoundException('Follow request not found');
}
return { rejected: true, requesterId: request.requesterId.toString() };
}
async getSuggestions(currentUserId: string, query: PaginationQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;

عرض الملف

@@ -0,0 +1,21 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { User } from '../../users/schemas/user.schema';
export type FollowRequestDocument = HydratedDocument<FollowRequest>;
@Schema({ timestamps: true, versionKey: false })
export class FollowRequest {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
requesterId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
targetUserId!: Types.ObjectId;
@Prop({ type: String, enum: ['pending', 'approved', 'rejected'], default: 'pending', index: true })
status!: 'pending' | 'approved' | 'rejected';
}
export const FollowRequestSchema = SchemaFactory.createForClass(FollowRequest);
FollowRequestSchema.index({ requesterId: 1, targetUserId: 1 }, { unique: true });
FollowRequestSchema.index({ targetUserId: 1, status: 1, createdAt: -1 });

عرض الملف

@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsMongoId } from 'class-validator';
import { IsEnum, IsIn, IsMongoId, IsOptional } from 'class-validator';
import { ReactionType } from '../../../common/enums/reaction-type.enum';
export class ToggleLikeDto {
@ApiProperty()
@@ -9,4 +10,9 @@ export class ToggleLikeDto {
@ApiProperty({ enum: ['post', 'comment'] })
@IsIn(['post', 'comment'])
targetType!: 'post' | 'comment';
@ApiProperty({ enum: ReactionType, required: false, default: ReactionType.LIKE })
@IsOptional()
@IsEnum(ReactionType)
reactionType?: ReactionType;
}

عرض الملف

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { ReactionType } from '../../common/enums/reaction-type.enum';
import { Like, LikeDocument } from './schemas/like.schema';
@Injectable()
@@ -17,14 +18,24 @@ export class LikesRepository {
.exec();
}
async create(userId: string, targetId: string, targetType: 'post' | 'comment'): Promise<LikeDocument> {
async create(
userId: string,
targetId: string,
targetType: 'post' | 'comment',
reactionType: ReactionType = ReactionType.LIKE,
): Promise<LikeDocument> {
return this.likeModel.create({
userId: new Types.ObjectId(userId),
targetId: new Types.ObjectId(targetId),
targetType,
reactionType,
});
}
async updateReaction(id: string, reactionType: ReactionType): Promise<LikeDocument | null> {
return this.likeModel.findByIdAndUpdate(id, { reactionType }, { new: true }).exec();
}
async findLikedPostIds(userId: string, postIds: string[]): Promise<string[]> {
if (!postIds.length) {
return [];
@@ -46,4 +57,20 @@ export class LikesRepository {
async deleteById(id: string): Promise<void> {
await this.likeModel.findByIdAndDelete(id).exec();
}
async getReactionSummary(targetId: string, targetType: 'post' | 'comment') {
const rows = await this.likeModel
.aggregate<{ _id: ReactionType; count: number }>([
{
$match: {
targetId: new Types.ObjectId(targetId),
targetType,
},
},
{ $group: { _id: '$reactionType', count: { $sum: 1 } } },
])
.exec();
return Object.fromEntries(rows.map((row) => [row._id ?? ReactionType.LIKE, row.count]));
}
}

عرض الملف

@@ -1,5 +1,6 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { ReactionType } from '../../common/enums/reaction-type.enum';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CommentsRepository } from '../comments/comments.repository';
@@ -19,21 +20,31 @@ export class LikesService {
private readonly notificationsService: NotificationsService,
) {}
async toggle(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
async toggle(userId: string, dto: ToggleLikeDto) {
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
return existing ? this.unlike(userId, dto) : this.like(userId, dto);
}
async like(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
async like(userId: string, dto: ToggleLikeDto) {
await this.assertTargetExists(dto);
const notificationContext = await this.resolveNotificationContext(dto);
const reactionType = dto.reactionType ?? ReactionType.LIKE;
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
if (existing) {
return { liked: true, targetId: dto.targetId, targetType: dto.targetType };
if ((existing.reactionType ?? ReactionType.LIKE) !== reactionType) {
await this.likesRepository.updateReaction(existing.id, reactionType);
}
return {
liked: true,
reacted: true,
targetId: dto.targetId,
targetType: dto.targetType,
reactionType,
};
}
await this.likesRepository.create(userId, dto.targetId, dto.targetType);
await this.likesRepository.create(userId, dto.targetId, dto.targetType, reactionType);
if (dto.targetType === 'post') {
await this.postsRepository.incrementLikesCount(dto.targetId, 1);
}
@@ -58,15 +69,15 @@ export class LikesService {
}
}
return { liked: true, targetId: dto.targetId, targetType: dto.targetType };
return { liked: true, reacted: true, targetId: dto.targetId, targetType: dto.targetType, reactionType };
}
async unlike(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
async unlike(userId: string, dto: ToggleLikeDto) {
await this.assertTargetExists(dto);
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
if (!existing) {
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
return { liked: false, reacted: false, targetId: dto.targetId, targetType: dto.targetType };
}
await this.likesRepository.deleteById(existing.id);
@@ -75,17 +86,27 @@ export class LikesService {
}
await this.feedVersionService.bumpGlobalVersion();
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
return { liked: false, reacted: false, targetId: dto.targetId, targetType: dto.targetType };
}
async getStatus(userId: string, dto: ToggleLikeDto): Promise<{ liked: boolean; targetId: string; targetType: string }> {
async getStatus(userId: string, dto: ToggleLikeDto) {
const targetExists = await this.targetExists(dto);
if (!targetExists) {
return { liked: false, targetId: dto.targetId, targetType: dto.targetType };
return { liked: false, reacted: false, targetId: dto.targetId, targetType: dto.targetType };
}
const existing = await this.likesRepository.findOne(userId, dto.targetId, dto.targetType);
return { liked: !!existing, targetId: dto.targetId, targetType: dto.targetType };
const [existing, reactionSummary] = await Promise.all([
this.likesRepository.findOne(userId, dto.targetId, dto.targetType),
this.likesRepository.getReactionSummary(dto.targetId, dto.targetType),
]);
return {
liked: !!existing,
reacted: !!existing,
targetId: dto.targetId,
targetType: dto.targetType,
reactionType: existing?.reactionType ?? null,
reactionSummary,
};
}
private async assertTargetExists(dto: ToggleLikeDto): Promise<void> {

عرض الملف

@@ -1,5 +1,6 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { ReactionType } from '../../../common/enums/reaction-type.enum';
export type LikeDocument = HydratedDocument<Like>;
@@ -13,6 +14,9 @@ export class Like {
@Prop({ required: true, enum: ['post', 'comment'] })
targetType!: 'post' | 'comment';
@Prop({ type: String, enum: Object.values(ReactionType), default: ReactionType.LIKE, index: true })
reactionType!: ReactionType;
}
export const LikeSchema = SchemaFactory.createForClass(Like);

عرض الملف

@@ -7,6 +7,7 @@ import {
IsMongoId,
IsNumber,
IsOptional,
IsBoolean,
IsString,
IsUrl,
Length,
@@ -82,6 +83,24 @@ export class CreatePostDto {
@IsUrl({ require_tld: false }, { each: true })
imageUrls?: string[];
@ApiPropertyOptional({ type: [String], description: 'Caption per carousel image, matched by order' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(10)
@IsString({ each: true })
@Length(0, 300, { each: true })
imageCaptions?: string[];
@ApiPropertyOptional({ type: [String], description: 'Alt text per carousel image, matched by order' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(10)
@IsString({ each: true })
@Length(0, 300, { each: true })
imageAltTexts?: string[];
@ApiPropertyOptional({ type: [String], description: 'Tagged user ids (max 20)' })
@IsOptional()
@Transform(toStringArray)
@@ -90,6 +109,14 @@ export class CreatePostDto {
@IsMongoId({ each: true })
taggedUserIds?: string[];
@ApiPropertyOptional({ type: [String], description: 'Collaborator user ids (max 5)' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(5)
@IsMongoId({ each: true })
collaboratorIds?: string[];
@ApiPropertyOptional({ type: [String], description: 'Mention usernames like rami_sabry (max 30)' })
@IsOptional()
@Transform(toStringArray)
@@ -123,4 +150,14 @@ export class CreatePostDto {
@IsOptional()
@IsEnum(PostVisibility)
visibility?: PostVisibility;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
commentsDisabled?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
commentsFollowersOnly?: boolean;
}

عرض الملف

@@ -0,0 +1,15 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, Length } from 'class-validator';
import { CreatePostDto } from './create-post.dto';
export class CreateRepostDto {
@ApiPropertyOptional({ maxLength: 2200, description: 'Optional quote text. Empty content creates a repost.' })
@IsOptional()
@IsString()
@Length(0, 2200)
content?: string;
@ApiPropertyOptional({ enum: ['public', 'followers', 'private'] })
@IsOptional()
visibility?: CreatePostDto['visibility'];
}

عرض الملف

@@ -0,0 +1,25 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
import { toStringArray } from '../../../common/utils/array-transform.util';
export class UpdateCommentSettingsDto {
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
commentsDisabled?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
commentsFollowersOnly?: boolean;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
@Length(1, 40, { each: true })
commentFilterKeywords?: string[];
}

عرض الملف

@@ -7,6 +7,7 @@ import {
IsMongoId,
IsNumber,
IsOptional,
IsBoolean,
IsString,
IsUrl,
Length,
@@ -82,6 +83,24 @@ export class UpdatePostDto {
@IsUrl({ require_tld: false }, { each: true })
imageUrls?: string[];
@ApiPropertyOptional({ type: [String], description: 'Caption per carousel image, matched by order' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(10)
@IsString({ each: true })
@Length(0, 300, { each: true })
imageCaptions?: string[];
@ApiPropertyOptional({ type: [String], description: 'Alt text per carousel image, matched by order' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(10)
@IsString({ each: true })
@Length(0, 300, { each: true })
imageAltTexts?: string[];
@ApiPropertyOptional({ type: [String], description: 'Set tagged user ids (max 20)' })
@IsOptional()
@Transform(toStringArray)
@@ -90,6 +109,14 @@ export class UpdatePostDto {
@IsMongoId({ each: true })
taggedUserIds?: string[];
@ApiPropertyOptional({ type: [String], description: 'Collaborator user ids (max 5)' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(5)
@IsMongoId({ each: true })
collaboratorIds?: string[];
@ApiPropertyOptional({ type: [String], description: 'Set mention usernames like rami_sabry (max 30)' })
@IsOptional()
@Transform(toStringArray)
@@ -123,4 +150,14 @@ export class UpdatePostDto {
@IsOptional()
@IsEnum(PostVisibility)
visibility?: PostVisibility;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
commentsDisabled?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
commentsFollowersOnly?: boolean;
}

عرض الملف

@@ -25,8 +25,10 @@ import { SuperAdminPermissions } from '../../common/decorators/superadmin-permis
import { AdminPostQueryDto } from './dto/admin-post-query.dto';
import { CreateReelDto } from './dto/create-reel.dto';
import { CreatePostDto } from './dto/create-post.dto';
import { CreateRepostDto } from './dto/create-repost.dto';
import { PostQueryDto } from './dto/post-query.dto';
import { ReelQueryDto } from './dto/reel-query.dto';
import { UpdateCommentSettingsDto } from './dto/update-comment-settings.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { PostsService } from './posts.service';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@@ -53,7 +55,10 @@ export class PostsController {
content: { type: 'string', example: 'First post #music' },
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
imageUrls: { type: 'array', items: { type: 'string' } },
imageCaptions: { type: 'array', items: { type: 'string' } },
imageAltTexts: { type: 'array', items: { type: 'string' } },
taggedUserIds: { type: 'array', items: { type: 'string' } },
collaboratorIds: { type: 'array', items: { type: 'string' } },
mentionUsernames: { type: 'array', items: { type: 'string' } },
location: { type: 'string', example: 'Riyadh, Saudi Arabia' },
latitude: { type: 'number', example: 24.7136 },
@@ -66,6 +71,8 @@ export class PostsController {
maqam: { type: 'string', example: 'Hijaz' },
rhythmSignature: { type: 'string', example: '6/8' },
waveformPeaks: { type: 'array', items: { type: 'number' } },
commentsDisabled: { type: 'boolean' },
commentsFollowersOnly: { type: 'boolean' },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
videoFile: { type: 'string', format: 'binary' },
audioFile: { type: 'string', format: 'binary' },
@@ -175,7 +182,10 @@ export class PostsController {
content: { type: 'string', example: 'Updated content' },
visibility: { type: 'string', enum: ['public', 'followers', 'private'] },
imageUrls: { type: 'array', items: { type: 'string' } },
imageCaptions: { type: 'array', items: { type: 'string' } },
imageAltTexts: { type: 'array', items: { type: 'string' } },
taggedUserIds: { type: 'array', items: { type: 'string' } },
collaboratorIds: { type: 'array', items: { type: 'string' } },
mentionUsernames: { type: 'array', items: { type: 'string' } },
location: { type: 'string', example: 'Jeddah, Saudi Arabia' },
latitude: { type: 'number', example: 21.5433 },
@@ -188,6 +198,8 @@ export class PostsController {
maqam: { type: 'string', example: 'Hijaz' },
rhythmSignature: { type: 'string', example: '6/8' },
waveformPeaks: { type: 'array', items: { type: 'number' } },
commentsDisabled: { type: 'boolean' },
commentsFollowersOnly: { type: 'boolean' },
imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } },
videoFile: { type: 'string', format: 'binary' },
audioFile: { type: 'string', format: 'binary' },
@@ -216,6 +228,56 @@ export class PostsController {
);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post(':postId/repost')
async repost(
@CurrentUser() user: JwtPayload,
@Param('postId') postId: string,
@Body() dto: CreateRepostDto,
) {
return this.postsService.createRepost(user.sub, postId, dto);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch(':postId/comment-settings')
async updateCommentSettings(
@CurrentUser() user: JwtPayload,
@Param('postId') postId: string,
@Body() dto: UpdateCommentSettingsDto,
) {
return this.postsService.updateCommentSettings(user.sub, postId, dto);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch(':postId/pin-profile')
async pinToProfile(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
return this.postsService.pinToProfile(user.sub, postId);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch(':postId/unpin-profile')
async unpinFromProfile(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
return this.postsService.unpinFromProfile(user.sub, postId);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch(':postId/archive')
async archive(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
return this.postsService.archive(user.sub, postId);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch(':postId/restore-archive')
async restoreArchive(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
return this.postsService.restoreArchived(user.sub, postId);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Delete(':postId')

عرض الملف

@@ -12,6 +12,7 @@ export class PostsRepository {
return {
...filter,
isDeleted: { $ne: true },
isArchived: { $ne: true },
moderationStatus: { $ne: ModerationStatus.HIDDEN },
};
}
@@ -39,6 +40,11 @@ export class PostsRepository {
.findOne({ _id: new Types.ObjectId(postId), isDeleted: { $ne: true } })
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
.populate({
path: 'repostOfPostId quoteOfPostId',
populate: { path: 'authorId', select: 'name username avatar isVerified stageName' },
})
.exec();
}
@@ -51,6 +57,11 @@ export class PostsRepository {
.findByIdAndUpdate(postId, payload, { new: true })
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
.populate({
path: 'repostOfPostId quoteOfPostId',
populate: { path: 'authorId', select: 'name username avatar isVerified stageName' },
})
.exec();
}
@@ -81,6 +92,11 @@ export class PostsRepository {
.find(this.withActiveFilter(filter))
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
.populate({
path: 'repostOfPostId quoteOfPostId',
populate: { path: 'authorId', select: 'name username avatar isVerified stageName' },
})
.sort(sort)
.skip(skip)
.limit(limit)
@@ -97,6 +113,7 @@ export class PostsRepository {
.find(this.withAdminFilter(filter))
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
.sort(sort)
.skip(skip)
.limit(limit)
@@ -113,6 +130,11 @@ export class PostsRepository {
.find({ _id: { $in: ids }, isDeleted: { $ne: true } })
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
.populate({
path: 'repostOfPostId quoteOfPostId',
populate: { path: 'authorId', select: 'name username avatar isVerified stageName' },
})
.exec();
const order = new Map(postIds.map((id, idx) => [id, idx]));
@@ -198,6 +220,7 @@ export class PostsRepository {
)
.populate({ path: 'authorId', select: 'name username avatar isVerified stageName' })
.populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
.populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
.exec();
}
}

عرض الملف

@@ -27,10 +27,12 @@ import { UsersRepository } from '../users/users.repository';
import { AdminPostQueryDto } from './dto/admin-post-query.dto';
import { CreateReelDto } from './dto/create-reel.dto';
import { CreatePostDto } from './dto/create-post.dto';
import { CreateRepostDto } from './dto/create-repost.dto';
import { PostQueryDto } from './dto/post-query.dto';
import { ReelQueryDto } from './dto/reel-query.dto';
import { UpdateCommentSettingsDto } from './dto/update-comment-settings.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { PostDocument, PostMediaVariantSet } from './schemas/post.schema';
import { PostDocument, PostImageItem, PostMediaVariantSet } from './schemas/post.schema';
import { PostsRepository } from './posts.repository';
type PostMediaMetadataInput = Pick<
@@ -124,6 +126,8 @@ export class PostsService {
const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || '';
const finalContent = dto.content?.trim() ?? '';
const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId);
const collaboratorIds = await this.normalizeUserIdList(dto.collaboratorIds, userId, 5, 'collaborator');
const imageItems = this.buildImageItems(finalImageUrls, dto.imageCaptions, dto.imageAltTexts);
const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, finalContent, userId);
const { location, latitude, longitude } = this.normalizeLocation(dto);
if (!finalContent && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) {
@@ -143,18 +147,22 @@ export class PostsService {
post = await this.postsRepository.create(userId, {
content: finalContent,
imageUrls: finalImageUrls,
imageItems,
imageVariants: finalImageVariants,
videoUrl: finalVideoUrl,
hlsUrl: uploadedHlsUrl,
audioUrl: finalAudioUrl,
thumbnailVariants: uploadedThumbnailVariants,
taggedUserIds,
collaboratorIds,
mentionUsernames: mentionResolution.mentionUsernames,
location,
latitude,
longitude,
postType,
visibility: dto.visibility ?? PostVisibility.PUBLIC,
commentsDisabled: dto.commentsDisabled ?? false,
commentsFollowersOnly: dto.commentsFollowersOnly ?? false,
hashtags,
...mediaMetadata,
});
@@ -269,6 +277,16 @@ export class PostsService {
typeof dto.taggedUserIds !== 'undefined'
? await this.normalizeTaggedUserIds(dto.taggedUserIds, userId)
: (post.taggedUserIds ?? []).map((id: Types.ObjectId | string) => new Types.ObjectId(id.toString()));
const nextCollaboratorIds =
typeof dto.collaboratorIds !== 'undefined'
? await this.normalizeUserIdList(dto.collaboratorIds, userId, 5, 'collaborator')
: (post.collaboratorIds ?? []).map((id: Types.ObjectId | string) => new Types.ObjectId(id.toString()));
const nextImageItems = this.buildImageItems(
nextImageUrls,
dto.imageCaptions,
dto.imageAltTexts,
hasImageUpdate ? [] : post.imageItems ?? [],
);
const previousMentionUsernames = this.normalizeMentionUsernames(post.mentionUsernames ?? []);
const shouldRecomputeMentions =
typeof dto.content === 'string' || typeof dto.mentionUsernames !== 'undefined';
@@ -309,10 +327,12 @@ export class PostsService {
...dto,
content: nextContent,
imageUrls: nextImageUrls,
imageItems: nextImageItems,
imageVariants: nextImageVariants,
hlsUrl: nextHlsUrl,
thumbnailVariants: nextThumbnailVariants,
taggedUserIds: nextTaggedUserIds,
collaboratorIds: nextCollaboratorIds,
mentionUsernames: mentionResolution.mentionUsernames,
location: nextLocation,
latitude: nextLatitude,
@@ -468,7 +488,10 @@ export class PostsService {
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const filter: Record<string, unknown> = { authorId: new Types.ObjectId(userId) };
const filter: Record<string, unknown> = {
authorId: new Types.ObjectId(userId),
isArchived: { $ne: true },
};
if (query.visibility) {
filter.visibility = query.visibility;
}
@@ -483,7 +506,7 @@ export class PostsService {
}
const direction = resolveMongoSortDirection(query.sortOrder);
const sortField = query.sortBy ?? 'createdAt';
const sort = { [sortField]: direction } as Record<string, 1 | -1>;
const sort = { pinnedToProfile: -1, [sortField]: direction } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.postsRepository.findMany(filter, skip, limit, sort),
@@ -676,6 +699,69 @@ export class PostsService {
};
}
async updateCommentSettings(
userId: string,
postId: string,
dto: UpdateCommentSettingsDto,
): Promise<PostDocument> {
await this.assertPostOwner(userId, postId);
const updated = await this.postsRepository.updateById(postId, {
...(typeof dto.commentsDisabled === 'boolean' ? { commentsDisabled: dto.commentsDisabled } : {}),
...(typeof dto.commentsFollowersOnly === 'boolean'
? { commentsFollowersOnly: dto.commentsFollowersOnly }
: {}),
...(Array.isArray(dto.commentFilterKeywords)
? {
commentFilterKeywords: Array.from(
new Set(dto.commentFilterKeywords.map((item) => item.trim().toLowerCase()).filter(Boolean)),
).slice(0, 50),
}
: {}),
});
if (!updated) {
throw new NotFoundException('Post not found');
}
return updated;
}
async pinToProfile(userId: string, postId: string): Promise<PostDocument> {
await this.assertPostOwner(userId, postId);
const updated = await this.postsRepository.updateById(postId, { pinnedToProfile: true });
if (!updated) {
throw new NotFoundException('Post not found');
}
return updated;
}
async unpinFromProfile(userId: string, postId: string): Promise<PostDocument> {
await this.assertPostOwner(userId, postId);
const updated = await this.postsRepository.updateById(postId, { pinnedToProfile: false });
if (!updated) {
throw new NotFoundException('Post not found');
}
return updated;
}
async archive(userId: string, postId: string): Promise<PostDocument> {
await this.assertPostOwner(userId, postId);
const updated = await this.postsRepository.updateById(postId, { isArchived: true, pinnedToProfile: false });
if (!updated) {
throw new NotFoundException('Post not found');
}
await this.feedVersionService.bumpGlobalVersion();
return updated;
}
async restoreArchived(userId: string, postId: string): Promise<PostDocument> {
await this.assertPostOwner(userId, postId);
const updated = await this.postsRepository.updateById(postId, { isArchived: false });
if (!updated) {
throw new NotFoundException('Post not found');
}
await this.feedVersionService.bumpGlobalVersion();
return updated;
}
async removeBySuperAdmin(superAdminIdentifier: string, postId: string): Promise<void> {
const post = await this.postsRepository.findById(postId);
if (!post) {
@@ -929,6 +1015,20 @@ export class PostsService {
return { location, latitude, longitude };
}
private buildImageItems(
imageUrls: string[],
captions: string[] | undefined,
altTexts: string[] | undefined,
fallback: PostImageItem[] = [],
): PostImageItem[] {
return imageUrls.map((url, index) => ({
url,
caption: typeof captions?.[index] === 'string' ? captions[index].trim() : fallback[index]?.caption ?? '',
altText: typeof altTexts?.[index] === 'string' ? altTexts[index].trim() : fallback[index]?.altText ?? '',
order: index,
}));
}
private async normalizeTaggedUserIds(
input: string[] | undefined,
authorId: string,
@@ -969,6 +1069,46 @@ export class PostsService {
return unique.map((id) => new Types.ObjectId(id));
}
private async normalizeUserIdList(
input: string[] | undefined,
currentUserId: string,
maxCount: number,
label: string,
): Promise<Types.ObjectId[]> {
if (!input?.length) {
return [];
}
const unique = Array.from(
new Set(
input
.map((item) => item?.trim())
.filter((item): item is string => !!item)
.filter((item) => item !== currentUserId),
),
);
if (unique.length > maxCount) {
throw new BadRequestException(`You can add up to ${maxCount} ${label}s only`);
}
if (unique.some((id) => !Types.ObjectId.isValid(id))) {
throw new BadRequestException(`Invalid ${label} user id`);
}
const rows = await this.usersRepository.findMany(
{ _id: { $in: unique.map((id) => new Types.ObjectId(id)) } },
0,
unique.length,
);
if (rows.length !== unique.length) {
throw new BadRequestException(`One or more ${label}s do not exist`);
}
return unique.map((id) => new Types.ObjectId(id));
}
private async notifyMentionedUsers(
actorId: string,
postId: string,
@@ -1047,6 +1187,37 @@ export class PostsService {
}
}
async createRepost(userId: string, sourcePostId: string, dto: CreateRepostDto): Promise<PostDocument> {
const sourcePost = await this.postsRepository.findById(sourcePostId);
if (!sourcePost) {
throw new NotFoundException('Source post not found');
}
if (this.extractEntityId(sourcePost.authorId) === userId && !dto.content?.trim()) {
throw new BadRequestException('You cannot repost your own post without a quote');
}
const content = dto.content?.trim() ?? '';
const mentionResolution = await this.resolveMentionTargets(undefined, content, userId);
const hashtags = this.extractHashtags(content);
const post = await this.postsRepository.create(userId, {
content,
postType: PostType.TEXT,
visibility: dto.visibility ?? PostVisibility.PUBLIC,
repostOfPostId: content ? null : new Types.ObjectId(sourcePostId),
quoteOfPostId: content ? new Types.ObjectId(sourcePostId) : null,
mentionUsernames: mentionResolution.mentionUsernames,
hashtags,
});
await this.usersRepository.incrementPostsCount(userId, 1);
await this.postsRepository.incrementShareCount(sourcePostId, 1);
await this.feedVersionService.bumpGlobalVersion();
await this.notifyMentionedUsers(userId, post.id, mentionResolution.mentionedUsers, content);
const populated = await this.postsRepository.findById(post.id);
return populated ?? post;
}
private async saveMediaFile(
mediaType: 'image' | 'video' | 'audio',
file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
@@ -1343,6 +1514,17 @@ export class PostsService {
return generateWaveformPeaksFromSeed(waveformSeed ?? 'audio-post');
}
private async assertPostOwner(userId: string, postId: string): Promise<PostDocument> {
const post = await this.postsRepository.findById(postId);
if (!post) {
throw new NotFoundException('Post not found');
}
if (this.extractEntityId(post.authorId) !== userId) {
throw new ForbiddenException('You can update only your own posts');
}
return post;
}
private extractEntityId(value: unknown): string {
if (!value) {
return '';

عرض الملف

@@ -20,6 +20,13 @@ export type PostMediaVariantSet = {
highUrl: string;
};
export type PostImageItem = {
url: string;
caption: string;
altText: string;
order: number;
};
const mediaVariantSetSchema = raw({
originalUrl: { type: String, default: '' },
lowUrl: { type: String, default: '' },
@@ -27,11 +34,24 @@ const mediaVariantSetSchema = raw({
highUrl: { type: String, default: '' },
});
const imageItemSchema = raw({
url: { type: String, default: '' },
caption: { type: String, default: '' },
altText: { type: String, default: '' },
order: { type: Number, default: 0 },
});
@Schema({ timestamps: true, versionKey: false })
export class Post {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
authorId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, ref: 'Post', default: null, index: true })
repostOfPostId?: Types.ObjectId | null;
@Prop({ type: Types.ObjectId, ref: 'Post', default: null, index: true })
quoteOfPostId?: Types.ObjectId | null;
@Prop({ default: '', trim: true, maxlength: 2200, required: true })
content!: string;
@@ -68,6 +88,9 @@ export class Post {
@Prop({ type: [String], default: [] })
imageUrls!: string[];
@Prop({ type: [imageItemSchema], default: [] })
imageItems!: PostImageItem[];
@Prop({ type: [mediaVariantSetSchema], default: [] })
imageVariants!: PostMediaVariantSet[];
@@ -77,6 +100,9 @@ export class Post {
@Prop({ type: [String], default: [] })
mentionUsernames!: string[];
@Prop({ type: [Types.ObjectId], ref: User.name, default: [], index: true })
collaboratorIds!: Types.ObjectId[];
@Prop({ default: '' })
location!: string;
@@ -110,6 +136,21 @@ export class Post {
@Prop({ default: 0, min: 0 })
playCount!: number;
@Prop({ default: false, index: true })
commentsDisabled!: boolean;
@Prop({ default: false })
commentsFollowersOnly!: boolean;
@Prop({ type: [String], default: [] })
commentFilterKeywords!: string[];
@Prop({ default: false, index: true })
pinnedToProfile!: boolean;
@Prop({ default: false, index: true })
isArchived!: boolean;
@Prop({ type: [String], default: [], index: true })
hashtags!: string[];
@@ -137,10 +178,15 @@ export class Post {
export const PostSchema = SchemaFactory.createForClass(Post);
PostSchema.index({ authorId: 1, createdAt: -1 });
PostSchema.index({ repostOfPostId: 1, createdAt: -1 });
PostSchema.index({ quoteOfPostId: 1, createdAt: -1 });
PostSchema.index({ visibility: 1, createdAt: -1 });
PostSchema.index({ postType: 1, createdAt: -1 });
PostSchema.index({ hashtags: 1, createdAt: -1 });
PostSchema.index({ taggedUserIds: 1, createdAt: -1 });
PostSchema.index({ collaboratorIds: 1, createdAt: -1 });
PostSchema.index({ authorId: 1, pinnedToProfile: -1, createdAt: -1 });
PostSchema.index({ authorId: 1, isArchived: 1, createdAt: -1 });
PostSchema.index({ moderationStatus: 1, createdAt: -1 });
PostSchema.index({ authorId: 1, isDeleted: 1, createdAt: -1 });
PostSchema.index({ visibility: 1, isDeleted: 1, createdAt: -1 });
@@ -158,6 +204,12 @@ PostSchema.index({
const transformManagedPostFiles = (_doc: unknown, ret: any) => {
ret.imageUrls = resolveManagedFileUrls(ret.imageUrls);
ret.imageItems = Array.isArray(ret.imageItems)
? ret.imageItems.map((item: PostImageItem) => ({
...item,
url: resolveManagedFileUrl(item.url),
}))
: [];
ret.imageVariants = resolveManagedFileUrlRecords(ret.imageVariants);
ret.videoUrl = resolveManagedFileUrl(ret.videoUrl);
ret.hlsUrl = resolveManagedFileUrl(ret.hlsUrl);

عرض الملف

@@ -0,0 +1,29 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsMongoId, IsOptional, IsString, Length } from 'class-validator';
import { ReportReason } from '../../../common/enums/report-reason.enum';
import { REPORT_TARGET_TYPES, ReportTargetType } from '../schemas/report.schema';
export class CreateReportDto {
@ApiProperty({ enum: REPORT_TARGET_TYPES })
@IsEnum(REPORT_TARGET_TYPES)
targetType!: ReportTargetType;
@ApiProperty()
@IsMongoId()
targetId!: string;
@ApiProperty({ enum: ReportReason })
@IsEnum(ReportReason)
reason!: ReportReason;
@ApiPropertyOptional({ maxLength: 2000 })
@IsOptional()
@IsString()
@Length(0, 2000)
details?: string;
@ApiPropertyOptional({ description: 'For user reports, block the reported user immediately' })
@IsOptional()
@IsBoolean()
blockTarget?: boolean;
}

عرض الملف

@@ -0,0 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { REPORT_STATUSES, REPORT_TARGET_TYPES, ReportStatus, ReportTargetType } from '../schemas/report.schema';
export class ReportQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: REPORT_TARGET_TYPES })
@IsOptional()
@IsEnum(REPORT_TARGET_TYPES)
targetType?: ReportTargetType;
@ApiPropertyOptional({ enum: REPORT_STATUSES })
@IsOptional()
@IsEnum(REPORT_STATUSES)
status?: ReportStatus;
}

عرض الملف

@@ -0,0 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, Length } from 'class-validator';
import { REPORT_STATUSES, ReportStatus } from '../schemas/report.schema';
export class UpdateReportStatusDto {
@ApiProperty({ enum: REPORT_STATUSES })
@IsEnum(REPORT_STATUSES)
status!: ReportStatus;
@ApiPropertyOptional({ maxLength: 300 })
@IsOptional()
@IsString()
@Length(0, 300)
resolutionNote?: string;
}

عرض الملف

@@ -0,0 +1,53 @@
import { Body, Controller, Get, Param, Patch, Post, Query, 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 { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
import { CreateReportDto } from './dto/create-report.dto';
import { ReportQueryDto } from './dto/report-query.dto';
import { UpdateReportStatusDto } from './dto/update-report-status.dto';
import { ReportsService } from './reports.service';
@ApiTags('Reports')
@Controller('reports')
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post()
async create(@CurrentUser() user: JwtPayload, @Body() dto: CreateReportDto) {
return this.reportsService.create(user.sub, dto);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('me')
async mine(@CurrentUser() user: JwtPayload, @Query() query: ReportQueryDto) {
return this.reportsService.listMine(user.sub, query);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
@Get('superadmin')
async superAdminList(@Query() query: ReportQueryDto) {
return this.reportsService.listForSuperAdmin(query);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
@Patch('superadmin/:reportId/status')
async updateStatus(
@CurrentUser() user: JwtPayload,
@Param('reportId') reportId: string,
@Body() dto: UpdateReportStatusDto,
) {
return this.reportsService.updateStatus(user.email ?? user.sub, reportId, dto);
}
}

عرض الملف

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { BlocksModule } from '../blocks/blocks.module';
import { ReportsController } from './reports.controller';
import { ReportsRepository } from './reports.repository';
import { ReportsService } from './reports.service';
import { Report, ReportSchema } from './schemas/report.schema';
@Module({
imports: [BlocksModule, MongooseModule.forFeature([{ name: Report.name, schema: ReportSchema }])],
controllers: [ReportsController],
providers: [ReportsService, ReportsRepository],
exports: [ReportsService, ReportsRepository],
})
export class ReportsModule {}

عرض الملف

@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model, Types } from 'mongoose';
import { Report, ReportDocument, ReportStatus } from './schemas/report.schema';
@Injectable()
export class ReportsRepository {
constructor(@InjectModel(Report.name) private readonly reportModel: Model<ReportDocument>) {}
async create(payload: {
reporterId: string;
targetType: string;
targetId: string;
reason: string;
details?: string;
}): Promise<ReportDocument> {
return this.reportModel
.findOneAndUpdate(
{
reporterId: new Types.ObjectId(payload.reporterId),
targetType: payload.targetType,
targetId: new Types.ObjectId(payload.targetId),
},
{
$setOnInsert: {
reporterId: new Types.ObjectId(payload.reporterId),
targetType: payload.targetType,
targetId: new Types.ObjectId(payload.targetId),
},
$set: {
reason: payload.reason,
details: payload.details ?? '',
status: 'open',
resolutionNote: '',
resolvedBy: '',
resolvedAt: null,
},
},
{ new: true, upsert: true },
)
.exec();
}
async findMany(filter: FilterQuery<ReportDocument>, skip: number, limit: number, sort: Record<string, 1 | -1>) {
return this.reportModel
.find(filter)
.populate({ path: 'reporterId', select: 'name username avatar stageName isVerified' })
.sort(sort)
.skip(skip)
.limit(limit)
.exec();
}
async count(filter: FilterQuery<ReportDocument>): Promise<number> {
return this.reportModel.countDocuments(filter).exec();
}
async updateStatus(
reportId: string,
status: ReportStatus,
resolutionNote: string,
resolvedBy: string,
): Promise<ReportDocument | null> {
return this.reportModel
.findByIdAndUpdate(
reportId,
{
status,
resolutionNote,
resolvedBy,
resolvedAt: ['resolved', 'rejected'].includes(status) ? new Date() : null,
},
{ new: true },
)
.exec();
}
}

عرض الملف

@@ -0,0 +1,146 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection, 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 { BlocksService } from '../blocks/blocks.service';
import { CreateReportDto } from './dto/create-report.dto';
import { ReportQueryDto } from './dto/report-query.dto';
import { UpdateReportStatusDto } from './dto/update-report-status.dto';
import { ReportsRepository } from './reports.repository';
@Injectable()
export class ReportsService {
constructor(
@InjectConnection() private readonly connection: Connection,
private readonly reportsRepository: ReportsRepository,
private readonly blocksService: BlocksService,
) {}
async create(reporterId: string, dto: CreateReportDto) {
await this.assertTargetExists(dto.targetType, dto.targetId);
const report = await this.reportsRepository.create({
reporterId,
targetType: dto.targetType,
targetId: dto.targetId,
reason: dto.reason,
details: dto.details?.trim() ?? '',
});
const automaticModeration = await this.applyAutomaticModeration(dto.targetType, dto.targetId, dto.reason);
const safetyActions = await this.applyReporterSafetyActions(reporterId, dto);
return { reported: true, item: report, automaticModeration, safetyActions };
}
async listMine(reporterId: string, query: ReportQueryDto) {
return this.list({ ...this.buildFilter(query), reporterId: new Types.ObjectId(reporterId) }, query);
}
async listForSuperAdmin(query: ReportQueryDto) {
return this.list(this.buildFilter(query), query);
}
async updateStatus(superAdminIdentifier: string, reportId: string, dto: UpdateReportStatusDto) {
if (!Types.ObjectId.isValid(reportId)) {
throw new NotFoundException('Report not found');
}
const updated = await this.reportsRepository.updateStatus(
reportId,
dto.status,
dto.resolutionNote?.trim() ?? '',
superAdminIdentifier,
);
if (!updated) {
throw new NotFoundException('Report not found');
}
return updated;
}
private async list(filter: Record<string, unknown>, query: ReportQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.reportsRepository.findMany(filter, skip, limit, sort),
this.reportsRepository.count(filter),
]);
return buildPaginatedResponse(items, { page, limit, total, offset: skip });
}
private buildFilter(query: ReportQueryDto): Record<string, unknown> {
const filter: Record<string, unknown> = {};
if (query.targetType) {
filter.targetType = query.targetType;
}
if (query.status) {
filter.status = query.status;
}
return filter;
}
private async assertTargetExists(targetType: string, targetId: string): Promise<void> {
if (!Types.ObjectId.isValid(targetId)) {
throw new BadRequestException('Invalid target id');
}
const collectionNameByType: Record<string, string> = {
user: 'users',
post: 'posts',
comment: 'comments',
listing: 'instruments',
repair_shop: 'repairshops',
};
const collectionName = collectionNameByType[targetType];
const target = collectionName
? await this.connection.collection(collectionName).findOne({ _id: new Types.ObjectId(targetId) })
: null;
if (!target) {
throw new NotFoundException('Report target not found');
}
}
private async applyAutomaticModeration(targetType: string, targetId: string, reason: string) {
if (!['post', 'comment'].includes(targetType)) {
return { status: 'not_applicable' };
}
const openReportsCount = await this.connection.collection('reports').countDocuments({
targetType,
targetId: new Types.ObjectId(targetId),
status: { $in: ['open', 'in_review'] },
});
if (openReportsCount < 3) {
return { status: 'watching', openReportsCount };
}
const collection = targetType === 'post' ? 'posts' : 'comments';
await this.connection.collection(collection).updateOne(
{ _id: new Types.ObjectId(targetId) },
{
$set: {
moderationStatus: ModerationStatus.FLAGGED,
moderationReason: `Automatically flagged after ${openReportsCount} reports. Latest reason: ${reason}`,
},
},
);
return { status: ModerationStatus.FLAGGED, openReportsCount };
}
private async applyReporterSafetyActions(reporterId: string, dto: CreateReportDto) {
if (dto.targetType !== 'user') {
return { blockAvailable: false, blocked: false };
}
if (!dto.blockTarget) {
return { blockAvailable: true, blocked: false };
}
await this.blocksService.block(reporterId, dto.targetId);
return { blockAvailable: true, blocked: true };
}
}

عرض الملف

@@ -0,0 +1,46 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { ReportReason } from '../../../common/enums/report-reason.enum';
import { User } from '../../users/schemas/user.schema';
export type ReportDocument = HydratedDocument<Report>;
export const REPORT_TARGET_TYPES = ['user', 'post', 'comment', 'listing', 'repair_shop'] as const;
export type ReportTargetType = (typeof REPORT_TARGET_TYPES)[number];
export const REPORT_STATUSES = ['open', 'in_review', 'resolved', 'rejected'] as const;
export type ReportStatus = (typeof REPORT_STATUSES)[number];
@Schema({ timestamps: true, versionKey: false })
export class Report {
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
reporterId!: Types.ObjectId;
@Prop({ type: String, enum: REPORT_TARGET_TYPES, required: true, index: true })
targetType!: ReportTargetType;
@Prop({ type: Types.ObjectId, required: true, index: true })
targetId!: Types.ObjectId;
@Prop({ type: String, enum: Object.values(ReportReason), required: true, index: true })
reason!: ReportReason;
@Prop({ default: '', trim: true, maxlength: 2000 })
details!: string;
@Prop({ type: String, enum: REPORT_STATUSES, default: 'open', index: true })
status!: ReportStatus;
@Prop({ default: '', trim: true, maxlength: 300 })
resolutionNote!: string;
@Prop({ type: String, default: '', index: true })
resolvedBy!: string;
@Prop({ type: Date, default: null })
resolvedAt!: Date | null;
}
export const ReportSchema = SchemaFactory.createForClass(Report);
ReportSchema.index({ reporterId: 1, targetType: 1, targetId: 1 }, { unique: true });
ReportSchema.index({ status: 1, createdAt: -1 });