Fix chat unread counter map updates
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-05-26 23:43:22 +03:00
الأصل 377bebfb88
التزام f82d6b8fe0
5 ملفات معدلة مع 187 إضافات و31 حذوفات

عرض الملف

@@ -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,

عرض الملف

@@ -0,0 +1,91 @@
import { Types } from 'mongoose';
import { ChatRepository } from './chat.repository';
const queryResult = <T>(value: T) => ({
exec: jest.fn().mockResolvedValue(value),
});
describe('ChatRepository unread counters', () => {
let conversationModel: Record<string, jest.Mock>;
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,
},
},
);
});
});

عرض الملف

@@ -35,9 +35,10 @@ export class ChatRepository {
title?: string;
createdBy: string;
}): Promise<ConversationDocument> {
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<string, number> = {};
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<string, number>(
Object.entries((conversation.unreadCountByUser as unknown as Record<string, number>) ?? {}),
);
const senderKey = senderId.toString();
const incrementUnreadCounts: 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);
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<string, number>;
await conversation.save();
const update: Record<string, unknown> = {
$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<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();
await this.conversationModel
.updateOne(
{ _id: new Types.ObjectId(conversationId) },
{
$set: {
[`unreadCountByUser.${userId.toString()}`]: 0,
},
},
)
.exec();
}
async unsendMessage(messageId: string, senderId: string): Promise<MessageDocument | null> {

عرض الملف

@@ -82,9 +82,11 @@ export class ChatService {
]);
const mappedItems = items.map((conversation) => {
const unreadMap = (conversation.unreadCountByUser as unknown as Record<string, number>) ?? {};
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<string, number> {
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<string, unknown>)
.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;

عرض الملف

@@ -29,10 +29,39 @@ export class Conversation {
@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 });
const normalizeUnreadCountByUser = (value: unknown): Record<string, number> => {
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<string, unknown>)
.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 });