Add Instagram-style social features and Postman collections

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

عرض الملف

@@ -108,12 +108,96 @@ Supported filters:
- `GET /notifications`
- `read`, `type`, `resourceType`, `sortOrder`
- `GET /comments/post/:postId`
- `page`, `limit`, `sortOrder`
- `page`, `limit`, `sortBy`, `sortOrder`
- `sortBy`: `createdAt`, `top`
- `POST /comments/:commentId/replies`
- JSON body: `content`
- the route `commentId` is the parent comment; clients do not send `postId`
- `GET /comments/:commentId/replies`
- `page`, `limit`, `sortBy`, `sortOrder`
- `sortBy`: `createdAt`, `top`
- `PATCH /comments/:commentId`
- JSON body: `content`, `mentionUsernames`
Comment list items include Instagram-like UI fields:
- `repliesCount`
- `repliesPreview`
- `likesCount`
- `likedByMe`
- `canEdit`
- `canDelete`
- `replyToUser`
## WebSocket auth
## Social upgrades
### Reposts and quote posts
- `POST /posts/:postId/repost`
- body `{ "content": "" }` creates a plain repost
- body `{ "content": "My take" }` creates a quote post
- repost items expose `repostOfPostId`; quote items expose `quoteOfPostId`
### Reactions
Existing likes endpoints now accept `reactionType`:
- `like`
- `love`
- `haha`
- `wow`
- `sad`
- `angry`
Example:
```json
{
"targetId": "...",
"targetType": "post",
"reactionType": "love"
}
```
`GET /likes/status/:targetType/:targetId` returns `reactionType` and `reactionSummary`.
### Private follow requests
When `targetUser.isPrivate=true`, `POST /follows/toggle` creates a pending request instead of following immediately.
- `GET /follows/requests`
- `PATCH /follows/requests/:requestId/approve`
- `PATCH /follows/requests/:requestId/reject`
`GET /follows/status/:targetUserId` returns `requested`.
### Reports
- `POST /reports`
- `targetType`: `user`, `post`, `comment`, `listing`, `repair_shop`
- `targetId`, `reason`, `details`
- `GET /reports/me`
- `GET /reports/superadmin`
- `PATCH /reports/superadmin/:reportId/status`
### Blocks
Global block endpoints:
- `POST /blocks/:targetUserId`
- `PATCH /blocks/:targetUserId/unblock`
- `GET /blocks/status/:targetUserId`
Blocked users are excluded from feed/trending/explore visibility.
### Explore
- `GET /feed/explore`
- public discovery feed using the trending ranking path
- respects global block visibility
Both namespaces accept the JWT access token in one of these places:
- `auth.token`
@@ -242,6 +326,41 @@ Recommended client behavior:
- on normal networks, use `imageUrls[index]` or `imageVariants[index].mediumUrl`
- on detail screens or zoom views, use `imageVariants[index].highUrl` or `imageVariants[index].originalUrl`
## Instagram-style social controls
Posts now support:
- carousel metadata through `imageItems[]` with `url`, `caption`, `altText`, and `order`
- `collaboratorIds[]` on create/update
- profile pinning through `PATCH /posts/:postId/pin-profile` and `/unpin-profile`
- archive/restore through `PATCH /posts/:postId/archive` and `/restore-archive`
- per-post comment settings through `PATCH /posts/:postId/comment-settings`
Comment settings:
- `commentsDisabled`
- `commentsFollowersOnly`
- `commentFilterKeywords[]`
Comments now support:
- `PATCH /comments/:commentId/pin`
- `PATCH /comments/:commentId/unpin`
- hidden offensive/filter matches via `hiddenByFilter`
Chat now supports:
- message media upload through `POST /chat/messages/upload`
- replies with `replyToMessageId`
- reactions through `PATCH /chat/messages/:messageId/reaction`
- delete-for-me through `PATCH /chat/messages/:messageId/delete-for-me`
Reports now require fixed `reason` values:
- `spam`, `harassment`, `hate_speech`, `nudity`, `violence`, `scam`, `intellectual_property`, `self_harm`, `other`
When a post/comment reaches multiple open reports, the backend can automatically flag it for moderation. User reports can include `blockTarget: true` to block the reported user immediately.
## Marketplace split
Marketplace is now separated from musical instruments at the API contract level:

تم حذف اختلاف الملف لأن الملف كبير جداً تحميل الاختلاف

تم حذف اختلاف الملف لأن الملف كبير جداً تحميل الاختلاف

تم حذف اختلاف الملف لأن الملف كبير جداً تحميل الاختلاف

عرض الملف

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