Fix chat unread counter map updates
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
@@ -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,
|
||||
|
||||
91
src/modules/chat/chat.repository.spec.ts
Normal file
91
src/modules/chat/chat.repository.spec.ts
Normal file
@@ -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 });
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم