Add Instagram-style social features and Postman collections
هذا الالتزام موجود في:
@@ -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[],
|
||||
|
||||
9
src/modules/chat/dto/message-reaction.dto.ts
Normal file
9
src/modules/chat/dto/message-reaction.dto.ts
Normal file
@@ -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);
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم