first commit
هذا الالتزام موجود في:
78
src/modules/chat/chat.controller.ts
Normal file
78
src/modules/chat/chat.controller.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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';
|
||||
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 { MessageQueryDto } from './dto/message-query.dto';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
|
||||
@ApiTags('Chat')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('chat')
|
||||
export class ChatController {
|
||||
constructor(private readonly chatService: ChatService) {}
|
||||
|
||||
@Post('conversations')
|
||||
@Throttle(40, 60_000)
|
||||
async createConversation(@CurrentUser() user: JwtPayload, @Body() dto: CreateConversationDto) {
|
||||
return this.chatService.createConversation(user.sub, dto);
|
||||
}
|
||||
|
||||
@Get('conversations')
|
||||
async myConversations(@CurrentUser() user: JwtPayload, @Query() query: MessageQueryDto) {
|
||||
return this.chatService.getMyConversations(user.sub, query);
|
||||
}
|
||||
|
||||
@Get('conversations/:conversationId/messages')
|
||||
async messages(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('conversationId') conversationId: string,
|
||||
@Query() query: MessageQueryDto,
|
||||
) {
|
||||
return this.chatService.getMessages(user.sub, conversationId, query);
|
||||
}
|
||||
|
||||
@Post('messages')
|
||||
@Throttle(120, 60_000)
|
||||
async sendMessage(@CurrentUser() user: JwtPayload, @Body() dto: SendMessageDto) {
|
||||
return this.chatService.sendMessage(user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch('messages/:messageId/seen')
|
||||
@Throttle(200, 60_000)
|
||||
async markSeen(@CurrentUser() user: JwtPayload, @Param('messageId') messageId: string) {
|
||||
return this.chatService.markMessageSeen(user.sub, messageId);
|
||||
}
|
||||
|
||||
@Patch('messages/:messageId/unsend')
|
||||
@Throttle(80, 60_000)
|
||||
async unsend(@CurrentUser() user: JwtPayload, @Param('messageId') messageId: string) {
|
||||
return this.chatService.unsendMessage(user.sub, messageId);
|
||||
}
|
||||
|
||||
@Post('blocks/:targetUserId')
|
||||
@Throttle(20, 60_000)
|
||||
async blockUser(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||
return this.chatService.blockUser(user.sub, targetUserId);
|
||||
}
|
||||
|
||||
@Patch('blocks/:targetUserId/unblock')
|
||||
@Throttle(20, 60_000)
|
||||
async unblockUser(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||
return this.chatService.unblockUser(user.sub, targetUserId);
|
||||
}
|
||||
|
||||
@Get('blocks/status/:targetUserId')
|
||||
async blockStatus(@CurrentUser() user: JwtPayload, @Param('targetUserId') targetUserId: string) {
|
||||
return this.chatService.getBlockStatus(user.sub, targetUserId);
|
||||
}
|
||||
|
||||
@Get('blocks')
|
||||
async myBlocks(@CurrentUser() user: JwtPayload) {
|
||||
return this.chatService.getMyBlockedUsers(user.sub);
|
||||
}
|
||||
}
|
||||
137
src/modules/chat/chat.gateway.ts
Normal file
137
src/modules/chat/chat.gateway.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { ChatService } from './chat.service';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
|
||||
type SocketWithUser = Socket & { data: { userId?: string } };
|
||||
|
||||
@WebSocketGateway({ cors: { origin: '*' }, namespace: 'chat' })
|
||||
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
constructor(
|
||||
private readonly chatService: ChatService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async handleConnection(client: SocketWithUser) {
|
||||
const token = this.extractToken(client);
|
||||
if (!token) {
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify<{ sub: string; tokenType: string }>(token, {
|
||||
secret: this.configService.get<string>('jwt.accessSecret', { infer: true }),
|
||||
});
|
||||
if (payload.tokenType !== 'access') {
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
client.data.userId = payload.sub;
|
||||
await client.join(this.userRoom(payload.sub));
|
||||
this.server.to(this.userRoom(payload.sub)).emit('presence', { userId: payload.sub, online: true });
|
||||
} catch {
|
||||
client.disconnect(true);
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: SocketWithUser) {
|
||||
const userId = client.data.userId;
|
||||
if (userId) {
|
||||
this.server.to(this.userRoom(userId)).emit('presence', { userId, online: false });
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('join_conversation')
|
||||
async joinConversation(
|
||||
@ConnectedSocket() client: SocketWithUser,
|
||||
@MessageBody() body: { conversationId: string },
|
||||
) {
|
||||
const userId = client.data.userId;
|
||||
if (!userId) return;
|
||||
|
||||
const conversation = await this.chatService.assertConversationMember(userId, body.conversationId);
|
||||
await client.join(this.conversationRoom(conversation.id));
|
||||
client.emit('joined_conversation', { conversationId: conversation.id });
|
||||
}
|
||||
|
||||
@SubscribeMessage('send_message')
|
||||
async sendMessage(
|
||||
@ConnectedSocket() client: SocketWithUser,
|
||||
@MessageBody() dto: SendMessageDto,
|
||||
) {
|
||||
const userId = client.data.userId;
|
||||
if (!userId) return;
|
||||
|
||||
const message = await this.chatService.sendMessage(userId, dto);
|
||||
this.server.to(this.conversationRoom(message.conversationId.toString())).emit('new_message', message);
|
||||
return message;
|
||||
}
|
||||
|
||||
@SubscribeMessage('typing')
|
||||
async typing(
|
||||
@ConnectedSocket() client: SocketWithUser,
|
||||
@MessageBody() body: { conversationId: string; isTyping: boolean },
|
||||
) {
|
||||
const userId = client.data.userId;
|
||||
if (!userId) return;
|
||||
|
||||
await this.chatService.assertConversationMember(userId, body.conversationId);
|
||||
client.to(this.conversationRoom(body.conversationId)).emit('typing', {
|
||||
conversationId: body.conversationId,
|
||||
userId,
|
||||
isTyping: !!body.isTyping,
|
||||
});
|
||||
}
|
||||
|
||||
@SubscribeMessage('mark_seen')
|
||||
async markSeen(
|
||||
@ConnectedSocket() client: SocketWithUser,
|
||||
@MessageBody() body: { messageId: string; conversationId: string },
|
||||
) {
|
||||
const userId = client.data.userId;
|
||||
if (!userId) return;
|
||||
|
||||
await this.chatService.markMessageSeen(userId, body.messageId);
|
||||
this.server.to(this.conversationRoom(body.conversationId)).emit('message_seen', {
|
||||
messageId: body.messageId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
private extractToken(client: Socket): string | null {
|
||||
const authToken = client.handshake.auth?.token;
|
||||
if (typeof authToken === 'string' && authToken.trim()) {
|
||||
return authToken.replace(/^Bearer\s+/i, '').trim();
|
||||
}
|
||||
|
||||
const headerAuth = client.handshake.headers.authorization;
|
||||
if (typeof headerAuth === 'string' && headerAuth.trim()) {
|
||||
return headerAuth.replace(/^Bearer\s+/i, '').trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private userRoom(userId: string): string {
|
||||
return `user:${userId}`;
|
||||
}
|
||||
|
||||
private conversationRoom(conversationId: string): string {
|
||||
return `conversation:${conversationId}`;
|
||||
}
|
||||
}
|
||||
29
src/modules/chat/chat.module.ts
Normal file
29
src/modules/chat/chat.module.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { ChatGateway } from './chat.gateway';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatRepository } from './chat.repository';
|
||||
import { ChatBlock, ChatBlockSchema } from './schemas/chat-block.schema';
|
||||
import { Conversation, ConversationSchema } from './schemas/conversation.schema';
|
||||
import { Message, MessageSchema } from './schemas/message.schema';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
JwtModule.register({}),
|
||||
UsersModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: Conversation.name, schema: ConversationSchema },
|
||||
{ name: Message.name, schema: MessageSchema },
|
||||
{ name: ChatBlock.name, schema: ChatBlockSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatService, ChatRepository, ChatGateway],
|
||||
exports: [ChatService],
|
||||
})
|
||||
export class ChatModule {}
|
||||
221
src/modules/chat/chat.repository.ts
Normal file
221
src/modules/chat/chat.repository.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model, Types } from 'mongoose';
|
||||
import { ChatBlock, ChatBlockDocument } from './schemas/chat-block.schema';
|
||||
import { Conversation, ConversationDocument } from './schemas/conversation.schema';
|
||||
import { Message, MessageDocument } from './schemas/message.schema';
|
||||
|
||||
@Injectable()
|
||||
export class ChatRepository {
|
||||
constructor(
|
||||
@InjectModel(Conversation.name) private readonly conversationModel: Model<ConversationDocument>,
|
||||
@InjectModel(Message.name) private readonly messageModel: Model<MessageDocument>,
|
||||
@InjectModel(ChatBlock.name) private readonly chatBlockModel: Model<ChatBlockDocument>,
|
||||
) {}
|
||||
|
||||
async findConversationById(id: string): Promise<ConversationDocument | null> {
|
||||
return this.conversationModel.findById(id).exec();
|
||||
}
|
||||
|
||||
async findDirectConversation(userAId: string, userBId: string): Promise<ConversationDocument | null> {
|
||||
return this.conversationModel
|
||||
.findOne({
|
||||
isGroup: false,
|
||||
participantIds: {
|
||||
$all: [new Types.ObjectId(userAId), new Types.ObjectId(userBId)],
|
||||
$size: 2,
|
||||
},
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async createConversation(payload: {
|
||||
participantIds: string[];
|
||||
isGroup: boolean;
|
||||
title?: string;
|
||||
createdBy: string;
|
||||
}): Promise<ConversationDocument> {
|
||||
const participantIds = payload.participantIds.map((id) => new Types.ObjectId(id));
|
||||
const unreadCountByUser: Record<string, number> = {};
|
||||
payload.participantIds.forEach((id) => {
|
||||
unreadCountByUser[id] = 0;
|
||||
});
|
||||
|
||||
return this.conversationModel.create({
|
||||
participantIds,
|
||||
isGroup: payload.isGroup,
|
||||
title: payload.title ?? '',
|
||||
createdBy: new Types.ObjectId(payload.createdBy),
|
||||
unreadCountByUser,
|
||||
lastMessageText: '',
|
||||
});
|
||||
}
|
||||
|
||||
async findConversationsForUser(userId: string, skip: number, limit: number): Promise<ConversationDocument[]> {
|
||||
return this.conversationModel
|
||||
.find({ participantIds: new Types.ObjectId(userId) })
|
||||
.populate({ path: 'participantIds', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({ lastMessageAt: -1, updatedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countConversationsForUser(userId: string): Promise<number> {
|
||||
return this.conversationModel.countDocuments({ participantIds: new Types.ObjectId(userId) }).exec();
|
||||
}
|
||||
|
||||
async createMessage(payload: {
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
content?: string;
|
||||
messageType: 'text' | 'image' | 'video' | 'audio';
|
||||
mediaUrl?: string;
|
||||
}): Promise<MessageDocument> {
|
||||
return this.messageModel.create({
|
||||
conversationId: new Types.ObjectId(payload.conversationId),
|
||||
senderId: new Types.ObjectId(payload.senderId),
|
||||
content: payload.content ?? '',
|
||||
messageType: payload.messageType,
|
||||
mediaUrl: payload.mediaUrl ?? '',
|
||||
seenBy: [new Types.ObjectId(payload.senderId)],
|
||||
isUnsent: false,
|
||||
});
|
||||
}
|
||||
|
||||
async findMessages(conversationId: string, skip: number, limit: number): Promise<MessageDocument[]> {
|
||||
return this.messageModel
|
||||
.find({ conversationId: new Types.ObjectId(conversationId) })
|
||||
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countMessages(conversationId: string): Promise<number> {
|
||||
return this.messageModel.countDocuments({ conversationId: new Types.ObjectId(conversationId) }).exec();
|
||||
}
|
||||
|
||||
async findMessageById(messageId: string): Promise<MessageDocument | null> {
|
||||
return this.messageModel.findById(messageId).exec();
|
||||
}
|
||||
|
||||
async markMessageSeen(messageId: string, userId: string): Promise<void> {
|
||||
await this.messageModel
|
||||
.findByIdAndUpdate(messageId, { $addToSet: { seenBy: new Types.ObjectId(userId) } }, { new: false })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async updateConversationAfterNewMessage(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
senderId: string,
|
||||
messageText: string,
|
||||
): Promise<ConversationDocument | null> {
|
||||
const conversation = await this.conversationModel.findById(conversationId).exec();
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unreadMap = new Map<string, number>(
|
||||
Object.entries((conversation.unreadCountByUser as unknown as Record<string, number>) ?? {}),
|
||||
);
|
||||
|
||||
for (const participantId of conversation.participantIds) {
|
||||
const id = participantId.toString();
|
||||
if (id === senderId) {
|
||||
unreadMap.set(id, 0);
|
||||
} else {
|
||||
unreadMap.set(id, (unreadMap.get(id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
conversation.lastMessageId = new Types.ObjectId(messageId);
|
||||
conversation.lastMessageText = messageText.slice(0, 4000);
|
||||
conversation.lastMessageAt = new Date();
|
||||
conversation.unreadCountByUser = unreadMap as unknown as Map<string, number>;
|
||||
await conversation.save();
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async clearConversationUnreadForUser(conversationId: string, userId: string): Promise<void> {
|
||||
const conversation = await this.conversationModel.findById(conversationId).exec();
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unreadMap = new Map<string, number>(
|
||||
Object.entries((conversation.unreadCountByUser as unknown as Record<string, number>) ?? {}),
|
||||
);
|
||||
unreadMap.set(userId, 0);
|
||||
conversation.unreadCountByUser = unreadMap as unknown as Map<string, number>;
|
||||
await conversation.save();
|
||||
}
|
||||
|
||||
async unsendMessage(messageId: string, senderId: string): Promise<MessageDocument | null> {
|
||||
return this.messageModel
|
||||
.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(messageId), senderId: new Types.ObjectId(senderId) },
|
||||
{
|
||||
isUnsent: true,
|
||||
content: '',
|
||||
mediaUrl: '',
|
||||
messageType: 'text',
|
||||
},
|
||||
{ new: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findManyMessages(filter: FilterQuery<MessageDocument>): Promise<MessageDocument[]> {
|
||||
return this.messageModel.find(filter).exec();
|
||||
}
|
||||
|
||||
async createBlock(blockerId: string, blockedId: string): Promise<void> {
|
||||
await this.chatBlockModel
|
||||
.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 removeBlock(blockerId: string, blockedId: string): Promise<void> {
|
||||
await this.chatBlockModel
|
||||
.deleteOne({ blockerId: new Types.ObjectId(blockerId), blockedId: new Types.ObjectId(blockedId) })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findBlock(blockerId: string, blockedId: string): Promise<ChatBlockDocument | null> {
|
||||
return this.chatBlockModel
|
||||
.findOne({ blockerId: new Types.ObjectId(blockerId), blockedId: new Types.ObjectId(blockedId) })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findAnyBlockBetween(userAId: string, userBId: string): Promise<ChatBlockDocument | null> {
|
||||
return this.chatBlockModel
|
||||
.findOne({
|
||||
$or: [
|
||||
{ blockerId: new Types.ObjectId(userAId), blockedId: new Types.ObjectId(userBId) },
|
||||
{ blockerId: new Types.ObjectId(userBId), blockedId: new Types.ObjectId(userAId) },
|
||||
],
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findBlocksByBlocker(blockerId: string): Promise<ChatBlockDocument[]> {
|
||||
return this.chatBlockModel
|
||||
.find({ blockerId: new Types.ObjectId(blockerId) })
|
||||
.populate({ path: 'blockedId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
250
src/modules/chat/chat.service.ts
Normal file
250
src/modules/chat/chat.service.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
import { CreateConversationDto } from './dto/create-conversation.dto';
|
||||
import { MessageQueryDto } from './dto/message-query.dto';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
import { ChatRepository } from './chat.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly chatRepository: ChatRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
) {}
|
||||
|
||||
async createConversation(currentUserId: string, dto: CreateConversationDto) {
|
||||
const uniqueParticipantIds = Array.from(new Set([currentUserId, ...dto.participantIds]));
|
||||
if (uniqueParticipantIds.length < 2) {
|
||||
throw new BadRequestException('Conversation must include at least 2 participants');
|
||||
}
|
||||
|
||||
for (const participantId of uniqueParticipantIds) {
|
||||
if (!Types.ObjectId.isValid(participantId)) {
|
||||
throw new BadRequestException('Invalid participant id');
|
||||
}
|
||||
}
|
||||
|
||||
const users = await Promise.all(uniqueParticipantIds.map((id) => this.usersRepository.findById(id)));
|
||||
if (users.some((u) => !u || u.isDisabled)) {
|
||||
throw new BadRequestException('One or more participants are invalid or disabled');
|
||||
}
|
||||
|
||||
const isGroup = dto.isGroup ?? uniqueParticipantIds.length > 2;
|
||||
if (!isGroup && uniqueParticipantIds.length !== 2) {
|
||||
throw new BadRequestException('Direct conversation must contain exactly 2 participants');
|
||||
}
|
||||
|
||||
if (!isGroup) {
|
||||
const otherId = uniqueParticipantIds.find((id) => id !== currentUserId) as string;
|
||||
const block = await this.chatRepository.findAnyBlockBetween(currentUserId, otherId);
|
||||
if (block) {
|
||||
throw new ForbiddenException('You cannot start chat with this user');
|
||||
}
|
||||
const existing = await this.chatRepository.findDirectConversation(currentUserId, otherId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
return this.chatRepository.createConversation({
|
||||
participantIds: uniqueParticipantIds,
|
||||
isGroup,
|
||||
title: dto.title,
|
||||
createdBy: currentUserId,
|
||||
});
|
||||
}
|
||||
|
||||
async getMyConversations(currentUserId: string, query: MessageQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.chatRepository.findConversationsForUser(currentUserId, skip, limit),
|
||||
this.chatRepository.countConversationsForUser(currentUserId),
|
||||
]);
|
||||
|
||||
const mappedItems = items.map((conversation) => {
|
||||
const unreadMap = (conversation.unreadCountByUser as unknown as Record<string, number>) ?? {};
|
||||
return {
|
||||
...conversation.toObject(),
|
||||
unreadCount: unreadMap[currentUserId] ?? 0,
|
||||
lastMessageAt: conversation.lastMessageAt ?? null,
|
||||
};
|
||||
});
|
||||
const nextOffset = skip + mappedItems.length;
|
||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||
|
||||
return {
|
||||
items: mappedItems,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) {
|
||||
const conversation = await this.assertConversationMember(currentUserId, conversationId);
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.chatRepository.findMessages(conversation.id, skip, limit),
|
||||
this.chatRepository.countMessages(conversation.id),
|
||||
]);
|
||||
|
||||
await this.chatRepository.clearConversationUnreadForUser(conversation.id, currentUserId);
|
||||
const nextOffset = skip + items.length;
|
||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async sendMessage(currentUserId: string, dto: SendMessageDto) {
|
||||
const conversation = await this.assertConversationMember(currentUserId, dto.conversationId);
|
||||
await this.assertNoChatBlockInConversation(currentUserId, conversation.participantIds.map((id) => id.toString()));
|
||||
const messageType = dto.messageType ?? 'text';
|
||||
const content = dto.content?.trim() ?? '';
|
||||
const mediaUrl = dto.mediaUrl?.trim() ?? '';
|
||||
|
||||
if (messageType === 'text' && !content) {
|
||||
throw new BadRequestException('Text message content is required');
|
||||
}
|
||||
|
||||
if (messageType !== 'text' && !mediaUrl) {
|
||||
throw new BadRequestException('mediaUrl is required for non-text messages');
|
||||
}
|
||||
|
||||
const message = await this.chatRepository.createMessage({
|
||||
conversationId: conversation.id,
|
||||
senderId: currentUserId,
|
||||
content,
|
||||
messageType,
|
||||
mediaUrl,
|
||||
});
|
||||
|
||||
const preview = messageType === 'text' ? content : `${messageType} message`;
|
||||
await this.chatRepository.updateConversationAfterNewMessage(
|
||||
conversation.id,
|
||||
message.id,
|
||||
currentUserId,
|
||||
preview,
|
||||
);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async markMessageSeen(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.markMessageSeen(message.id, currentUserId);
|
||||
await this.chatRepository.clearConversationUnreadForUser(message.conversationId.toString(), currentUserId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async unsendMessage(currentUserId: string, messageId: string) {
|
||||
const message = await this.chatRepository.findMessageById(messageId);
|
||||
if (!message) {
|
||||
throw new NotFoundException('Message not found');
|
||||
}
|
||||
if (message.senderId.toString() !== currentUserId) {
|
||||
throw new ForbiddenException('You can only unsend your own messages');
|
||||
}
|
||||
|
||||
const updated = await this.chatRepository.unsendMessage(messageId, currentUserId);
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Message not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async blockUser(currentUserId: string, targetUserId: string) {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
if (currentUserId === targetUserId) {
|
||||
throw new BadRequestException('You cannot block yourself');
|
||||
}
|
||||
|
||||
const target = await this.usersRepository.findById(targetUserId);
|
||||
if (!target) {
|
||||
throw new NotFoundException('Target user not found');
|
||||
}
|
||||
|
||||
await this.chatRepository.createBlock(currentUserId, targetUserId);
|
||||
return { blocked: true, targetUserId };
|
||||
}
|
||||
|
||||
async unblockUser(currentUserId: string, targetUserId: string) {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
await this.chatRepository.removeBlock(currentUserId, targetUserId);
|
||||
return { blocked: false, targetUserId };
|
||||
}
|
||||
|
||||
async getBlockStatus(currentUserId: string, targetUserId: string) {
|
||||
if (!Types.ObjectId.isValid(targetUserId)) {
|
||||
throw new BadRequestException('Invalid target user id');
|
||||
}
|
||||
|
||||
const iBlocked = !!(await this.chatRepository.findBlock(currentUserId, targetUserId));
|
||||
const blockedMe = !!(await this.chatRepository.findBlock(targetUserId, currentUserId));
|
||||
|
||||
return { targetUserId, iBlocked, blockedMe };
|
||||
}
|
||||
|
||||
async getMyBlockedUsers(currentUserId: string) {
|
||||
const items = await this.chatRepository.findBlocksByBlocker(currentUserId);
|
||||
return { items };
|
||||
}
|
||||
|
||||
async assertConversationMember(userId: string, conversationId: string) {
|
||||
if (!Types.ObjectId.isValid(conversationId)) {
|
||||
throw new BadRequestException('Invalid conversation id');
|
||||
}
|
||||
|
||||
const conversation = await this.chatRepository.findConversationById(conversationId);
|
||||
if (!conversation) {
|
||||
throw new NotFoundException('Conversation not found');
|
||||
}
|
||||
|
||||
const isMember = conversation.participantIds.some((id) => id.toString() === userId);
|
||||
if (!isMember) {
|
||||
throw new ForbiddenException('You are not a member of this conversation');
|
||||
}
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private async assertNoChatBlockInConversation(currentUserId: string, participantIds: string[]) {
|
||||
for (const participantId of participantIds) {
|
||||
if (participantId === currentUserId) {
|
||||
continue;
|
||||
}
|
||||
const block = await this.chatRepository.findAnyBlockBetween(currentUserId, participantId);
|
||||
if (block) {
|
||||
throw new ForbiddenException('Cannot send message because one of participants is blocked');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/modules/chat/dto/create-conversation.dto.ts
Normal file
19
src/modules/chat/dto/create-conversation.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString, Length } from 'class-validator';
|
||||
|
||||
export class CreateConversationDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
participantIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isGroup?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 120 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 120)
|
||||
title?: string;
|
||||
}
|
||||
3
src/modules/chat/dto/message-query.dto.ts
Normal file
3
src/modules/chat/dto/message-query.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export class MessageQueryDto extends PaginationQueryDto {}
|
||||
23
src/modules/chat/dto/send-message.dto.ts
Normal file
23
src/modules/chat/dto/send-message.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUrl, Length } from 'class-validator';
|
||||
|
||||
export class SendMessageDto {
|
||||
@IsString()
|
||||
conversationId!: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 4000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 4000)
|
||||
content?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['text', 'image', 'video', 'audio'], default: 'text' })
|
||||
@IsOptional()
|
||||
@IsEnum(['text', 'image', 'video', 'audio'])
|
||||
messageType?: 'text' | 'image' | 'video' | 'audio';
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false })
|
||||
mediaUrl?: string;
|
||||
}
|
||||
17
src/modules/chat/schemas/chat-block.schema.ts
Normal file
17
src/modules/chat/schemas/chat-block.schema.ts
Normal file
@@ -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 ChatBlockDocument = HydratedDocument<ChatBlock>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class ChatBlock {
|
||||
@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 ChatBlockSchema = SchemaFactory.createForClass(ChatBlock);
|
||||
ChatBlockSchema.index({ blockerId: 1, blockedId: 1 }, { unique: true });
|
||||
37
src/modules/chat/schemas/conversation.schema.ts
Normal file
37
src/modules/chat/schemas/conversation.schema.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type ConversationDocument = HydratedDocument<Conversation>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Conversation {
|
||||
@Prop({ type: [Types.ObjectId], ref: User.name, required: true, index: true })
|
||||
participantIds!: Types.ObjectId[];
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
isGroup!: boolean;
|
||||
|
||||
@Prop({ default: '', maxlength: 120, trim: true })
|
||||
title!: string;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: false, index: true })
|
||||
createdBy?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, required: false, index: true })
|
||||
lastMessageId?: Types.ObjectId;
|
||||
|
||||
@Prop({ default: '', maxlength: 4000 })
|
||||
lastMessageText!: string;
|
||||
|
||||
@Prop({ type: Date, required: false, index: true })
|
||||
lastMessageAt?: Date;
|
||||
|
||||
@Prop({ type: Map, of: Number, default: {} })
|
||||
unreadCountByUser!: Map<string, number>;
|
||||
}
|
||||
|
||||
export const ConversationSchema = SchemaFactory.createForClass(Conversation);
|
||||
ConversationSchema.index({ participantIds: 1, updatedAt: -1 });
|
||||
ConversationSchema.index({ lastMessageAt: -1, updatedAt: -1 });
|
||||
ConversationSchema.index({ participantIds: 1, isGroup: 1, lastMessageAt: -1 });
|
||||
33
src/modules/chat/schemas/message.schema.ts
Normal file
33
src/modules/chat/schemas/message.schema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type MessageDocument = HydratedDocument<Message>;
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class Message {
|
||||
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||
conversationId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
senderId!: Types.ObjectId;
|
||||
|
||||
@Prop({ required: false, default: '', maxlength: 4000 })
|
||||
content!: string;
|
||||
|
||||
@Prop({ enum: ['text', 'image', 'video', 'audio'], default: 'text', index: true })
|
||||
messageType!: 'text' | 'image' | 'video' | 'audio';
|
||||
|
||||
@Prop({ required: false, default: '' })
|
||||
mediaUrl!: string;
|
||||
|
||||
@Prop({ type: [Types.ObjectId], ref: User.name, default: [] })
|
||||
seenBy!: Types.ObjectId[];
|
||||
|
||||
@Prop({ default: false, index: true })
|
||||
isUnsent!: boolean;
|
||||
}
|
||||
|
||||
export const MessageSchema = SchemaFactory.createForClass(Message);
|
||||
MessageSchema.index({ conversationId: 1, createdAt: -1 });
|
||||
MessageSchema.index({ conversationId: 1, isUnsent: 1, createdAt: -1 });
|
||||
المرجع في مشكلة جديدة
حظر مستخدم