From f82d6b8fe097b73e44abf8919b014264513974a0 Mon Sep 17 00:00:00 2001 From: boutmoun123 Date: Tue, 26 May 2026 23:43:22 +0300 Subject: [PATCH] Fix chat unread counter map updates --- src/modules/chat/chat.controller.ts | 6 ++ src/modules/chat/chat.repository.spec.ts | 91 +++++++++++++++++++ src/modules/chat/chat.repository.ts | 58 ++++++------ src/modules/chat/chat.service.ts | 32 ++++++- .../chat/schemas/conversation.schema.ts | 31 ++++++- 5 files changed, 187 insertions(+), 31 deletions(-) create mode 100644 src/modules/chat/chat.repository.spec.ts diff --git a/src/modules/chat/chat.controller.ts b/src/modules/chat/chat.controller.ts index e95ff8c..249b7c8 100644 --- a/src/modules/chat/chat.controller.ts +++ b/src/modules/chat/chat.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Get, @@ -40,6 +41,11 @@ export class ChatController { return this.chatService.getMyConversations(user.sub, query); } + @Get('conversations/messages') + async messagesMissingConversationId() { + throw new BadRequestException('conversationId is required. Use /chat/conversations/:conversationId/messages'); + } + @Get('conversations/:conversationId/messages') async messages( @CurrentUser() user: JwtPayload, diff --git a/src/modules/chat/chat.repository.spec.ts b/src/modules/chat/chat.repository.spec.ts new file mode 100644 index 0000000..a2489a4 --- /dev/null +++ b/src/modules/chat/chat.repository.spec.ts @@ -0,0 +1,91 @@ +import { Types } from 'mongoose'; +import { ChatRepository } from './chat.repository'; + +const queryResult = (value: T) => ({ + exec: jest.fn().mockResolvedValue(value), +}); + +describe('ChatRepository unread counters', () => { + let conversationModel: Record; + let repository: ChatRepository; + + beforeEach(() => { + conversationModel = { + create: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), + updateOne: jest.fn(), + }; + repository = new ChatRepository(conversationModel as any, {} as any, {} as any); + }); + + it('creates conversations with string user ids as unread counter keys', async () => { + const userA = new Types.ObjectId().toString(); + const userB = new Types.ObjectId().toString(); + conversationModel.create.mockResolvedValue({ id: 'conversation-1' }); + + await repository.createConversation({ + participantIds: [userA, userB], + isGroup: false, + createdBy: userA, + }); + + expect(conversationModel.create).toHaveBeenCalledWith( + expect.objectContaining({ + unreadCountByUser: { + [userA]: 0, + [userB]: 0, + }, + }), + ); + }); + + it('increments unread count for recipients and resets sender with atomic updates', async () => { + const conversationId = new Types.ObjectId().toString(); + const messageId = new Types.ObjectId().toString(); + const senderId = new Types.ObjectId().toString(); + const recipientId = new Types.ObjectId().toString(); + const updatedConversation = { id: conversationId }; + + conversationModel.findById.mockReturnValue( + queryResult({ + participantIds: [new Types.ObjectId(senderId), new Types.ObjectId(recipientId)], + }), + ); + conversationModel.findByIdAndUpdate.mockReturnValue(queryResult(updatedConversation)); + + await repository.updateConversationAfterNewMessage(conversationId, messageId, senderId, 'hello'); + + expect(conversationModel.findByIdAndUpdate).toHaveBeenCalledWith( + conversationId, + expect.objectContaining({ + $set: expect.objectContaining({ + lastMessageId: expect.any(Types.ObjectId), + lastMessageText: 'hello', + [`unreadCountByUser.${senderId}`]: 0, + }), + $inc: { + [`unreadCountByUser.${recipientId}`]: 1, + }, + }), + { new: true }, + ); + }); + + it('clears unread count for current user without saving a Mongoose Map', async () => { + const conversationId = new Types.ObjectId().toString(); + const userId = new Types.ObjectId().toString(); + conversationModel.updateOne.mockReturnValue(queryResult({ modifiedCount: 1 })); + + await repository.clearConversationUnreadForUser(conversationId, userId); + + expect(conversationModel.updateOne).toHaveBeenCalledWith( + { _id: new Types.ObjectId(conversationId) }, + { + $set: { + [`unreadCountByUser.${userId}`]: 0, + }, + }, + ); + }); +}); diff --git a/src/modules/chat/chat.repository.ts b/src/modules/chat/chat.repository.ts index a726ed0..1b6a431 100644 --- a/src/modules/chat/chat.repository.ts +++ b/src/modules/chat/chat.repository.ts @@ -35,9 +35,10 @@ export class ChatRepository { title?: string; createdBy: string; }): Promise { - const participantIds = payload.participantIds.map((id) => new Types.ObjectId(id)); + const participantIdStrings = payload.participantIds.map((id) => id.toString()); + const participantIds = participantIdStrings.map((id) => new Types.ObjectId(id)); const unreadCountByUser: Record = {}; - payload.participantIds.forEach((id) => { + participantIdStrings.forEach((id) => { unreadCountByUser[id] = 0; }); @@ -141,7 +142,7 @@ export class ChatRepository { return this.messageModel .findByIdAndUpdate( messageId, - { $set: { [`reactionsByUser.${userId}`]: reactionType } }, + { $set: { [`reactionsByUser.${userId.toString()}`]: reactionType } }, { new: true }, ) .populate({ path: 'senderId', select: 'name username stageName avatar isVerified' }) @@ -165,40 +166,41 @@ export class ChatRepository { return null; } - const unreadMap = new Map( - Object.entries((conversation.unreadCountByUser as unknown as Record) ?? {}), - ); - + const senderKey = senderId.toString(); + const incrementUnreadCounts: Record = {}; 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); + if (id !== senderKey) { + incrementUnreadCounts[`unreadCountByUser.${id}`] = 1; } } - conversation.lastMessageId = new Types.ObjectId(messageId); - conversation.lastMessageText = messageText.slice(0, 4000); - conversation.lastMessageAt = new Date(); - conversation.unreadCountByUser = unreadMap as unknown as Map; - await conversation.save(); + const update: Record = { + $set: { + lastMessageId: new Types.ObjectId(messageId), + lastMessageText: messageText.slice(0, 4000), + lastMessageAt: new Date(), + [`unreadCountByUser.${senderKey}`]: 0, + }, + }; + if (Object.keys(incrementUnreadCounts).length > 0) { + update.$inc = incrementUnreadCounts; + } - return conversation; + return this.conversationModel.findByIdAndUpdate(conversationId, update, { new: true }).exec(); } async clearConversationUnreadForUser(conversationId: string, userId: string): Promise { - const conversation = await this.conversationModel.findById(conversationId).exec(); - if (!conversation) { - return; - } - - const unreadMap = new Map( - Object.entries((conversation.unreadCountByUser as unknown as Record) ?? {}), - ); - unreadMap.set(userId, 0); - conversation.unreadCountByUser = unreadMap as unknown as Map; - await conversation.save(); + await this.conversationModel + .updateOne( + { _id: new Types.ObjectId(conversationId) }, + { + $set: { + [`unreadCountByUser.${userId.toString()}`]: 0, + }, + }, + ) + .exec(); } async unsendMessage(messageId: string, senderId: string): Promise { diff --git a/src/modules/chat/chat.service.ts b/src/modules/chat/chat.service.ts index 81bad56..a5f557c 100644 --- a/src/modules/chat/chat.service.ts +++ b/src/modules/chat/chat.service.ts @@ -82,9 +82,11 @@ export class ChatService { ]); const mappedItems = items.map((conversation) => { - const unreadMap = (conversation.unreadCountByUser as unknown as Record) ?? {}; + const conversationObject = conversation.toObject(); + const unreadMap = this.normalizeUnreadCountByUser(conversationObject.unreadCountByUser); return { - ...conversation.toObject(), + ...conversationObject, + unreadCountByUser: unreadMap, unreadCount: unreadMap[currentUserId] ?? 0, lastMessageAt: conversation.lastMessageAt ?? null, }; @@ -104,6 +106,10 @@ export class ChatService { } async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) { + if (!conversationId?.trim()) { + throw new BadRequestException('conversationId is required'); + } + const conversation = await this.assertConversationMember(currentUserId, conversationId); const page = query.page ?? 1; const limit = query.limit ?? 20; @@ -326,6 +332,28 @@ export class ChatService { } } + private normalizeUnreadCountByUser(value: unknown): Record { + if (!value) { + return {}; + } + + if (value instanceof Map) { + return Object.fromEntries( + Array.from(value.entries()).map(([key, count]) => [key.toString(), Number(count) || 0]), + ); + } + + if (typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([key]) => !key.startsWith('$__') && key !== '$isMongooseMap') + .map(([key, count]) => [key, Number(count) || 0]), + ); + } + + return {}; + } + private async resolveReplyToMessageId(replyToMessageId: string | undefined, conversationId: string) { if (!replyToMessageId) { return null; diff --git a/src/modules/chat/schemas/conversation.schema.ts b/src/modules/chat/schemas/conversation.schema.ts index 429c82e..98bf53a 100644 --- a/src/modules/chat/schemas/conversation.schema.ts +++ b/src/modules/chat/schemas/conversation.schema.ts @@ -29,10 +29,39 @@ export class Conversation { @Prop({ type: Map, of: Number, default: {} }) unreadCountByUser!: Map; - } 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 }); + +const normalizeUnreadCountByUser = (value: unknown): Record => { + if (!value) { + return {}; + } + + if (value instanceof Map) { + return Object.fromEntries( + Array.from(value.entries()).map(([key, count]) => [key.toString(), Number(count) || 0]), + ); + } + + if (typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([key]) => !key.startsWith('$__') && key !== '$isMongooseMap') + .map(([key, count]) => [key, Number(count) || 0]), + ); + } + + return {}; +}; + +const transformConversation = (_doc: unknown, ret: any) => { + ret.unreadCountByUser = normalizeUnreadCountByUser(ret.unreadCountByUser); + return ret; +}; + +ConversationSchema.set('toJSON', { transform: transformConversation }); +ConversationSchema.set('toObject', { transform: transformConversation });