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 {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -40,6 +41,11 @@ export class ChatController {
|
|||||||
return this.chatService.getMyConversations(user.sub, query);
|
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')
|
@Get('conversations/:conversationId/messages')
|
||||||
async messages(
|
async messages(
|
||||||
@CurrentUser() user: JwtPayload,
|
@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;
|
title?: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
}): Promise<ConversationDocument> {
|
}): 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> = {};
|
const unreadCountByUser: Record<string, number> = {};
|
||||||
payload.participantIds.forEach((id) => {
|
participantIdStrings.forEach((id) => {
|
||||||
unreadCountByUser[id] = 0;
|
unreadCountByUser[id] = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ export class ChatRepository {
|
|||||||
return this.messageModel
|
return this.messageModel
|
||||||
.findByIdAndUpdate(
|
.findByIdAndUpdate(
|
||||||
messageId,
|
messageId,
|
||||||
{ $set: { [`reactionsByUser.${userId}`]: reactionType } },
|
{ $set: { [`reactionsByUser.${userId.toString()}`]: reactionType } },
|
||||||
{ new: true },
|
{ new: true },
|
||||||
)
|
)
|
||||||
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
|
.populate({ path: 'senderId', select: 'name username stageName avatar isVerified' })
|
||||||
@@ -165,40 +166,41 @@ export class ChatRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unreadMap = new Map<string, number>(
|
const senderKey = senderId.toString();
|
||||||
Object.entries((conversation.unreadCountByUser as unknown as Record<string, number>) ?? {}),
|
const incrementUnreadCounts: Record<string, number> = {};
|
||||||
);
|
|
||||||
|
|
||||||
for (const participantId of conversation.participantIds) {
|
for (const participantId of conversation.participantIds) {
|
||||||
const id = participantId.toString();
|
const id = participantId.toString();
|
||||||
if (id === senderId) {
|
if (id !== senderKey) {
|
||||||
unreadMap.set(id, 0);
|
incrementUnreadCounts[`unreadCountByUser.${id}`] = 1;
|
||||||
} else {
|
|
||||||
unreadMap.set(id, (unreadMap.get(id) ?? 0) + 1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conversation.lastMessageId = new Types.ObjectId(messageId);
|
const update: Record<string, unknown> = {
|
||||||
conversation.lastMessageText = messageText.slice(0, 4000);
|
$set: {
|
||||||
conversation.lastMessageAt = new Date();
|
lastMessageId: new Types.ObjectId(messageId),
|
||||||
conversation.unreadCountByUser = unreadMap as unknown as Map<string, number>;
|
lastMessageText: messageText.slice(0, 4000),
|
||||||
await conversation.save();
|
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> {
|
async clearConversationUnreadForUser(conversationId: string, userId: string): Promise<void> {
|
||||||
const conversation = await this.conversationModel.findById(conversationId).exec();
|
await this.conversationModel
|
||||||
if (!conversation) {
|
.updateOne(
|
||||||
return;
|
{ _id: new Types.ObjectId(conversationId) },
|
||||||
}
|
{
|
||||||
|
$set: {
|
||||||
const unreadMap = new Map<string, number>(
|
[`unreadCountByUser.${userId.toString()}`]: 0,
|
||||||
Object.entries((conversation.unreadCountByUser as unknown as Record<string, number>) ?? {}),
|
},
|
||||||
);
|
},
|
||||||
unreadMap.set(userId, 0);
|
)
|
||||||
conversation.unreadCountByUser = unreadMap as unknown as Map<string, number>;
|
.exec();
|
||||||
await conversation.save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async unsendMessage(messageId: string, senderId: string): Promise<MessageDocument | null> {
|
async unsendMessage(messageId: string, senderId: string): Promise<MessageDocument | null> {
|
||||||
|
|||||||
@@ -82,9 +82,11 @@ export class ChatService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const mappedItems = items.map((conversation) => {
|
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 {
|
return {
|
||||||
...conversation.toObject(),
|
...conversationObject,
|
||||||
|
unreadCountByUser: unreadMap,
|
||||||
unreadCount: unreadMap[currentUserId] ?? 0,
|
unreadCount: unreadMap[currentUserId] ?? 0,
|
||||||
lastMessageAt: conversation.lastMessageAt ?? null,
|
lastMessageAt: conversation.lastMessageAt ?? null,
|
||||||
};
|
};
|
||||||
@@ -104,6 +106,10 @@ export class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) {
|
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 conversation = await this.assertConversationMember(currentUserId, conversationId);
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const limit = query.limit ?? 20;
|
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) {
|
private async resolveReplyToMessageId(replyToMessageId: string | undefined, conversationId: string) {
|
||||||
if (!replyToMessageId) {
|
if (!replyToMessageId) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -29,10 +29,39 @@ export class Conversation {
|
|||||||
|
|
||||||
@Prop({ type: Map, of: Number, default: {} })
|
@Prop({ type: Map, of: Number, default: {} })
|
||||||
unreadCountByUser!: Map<string, number>;
|
unreadCountByUser!: Map<string, number>;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConversationSchema = SchemaFactory.createForClass(Conversation);
|
export const ConversationSchema = SchemaFactory.createForClass(Conversation);
|
||||||
ConversationSchema.index({ participantIds: 1, updatedAt: -1 });
|
ConversationSchema.index({ participantIds: 1, updatedAt: -1 });
|
||||||
ConversationSchema.index({ lastMessageAt: -1, updatedAt: -1 });
|
ConversationSchema.index({ lastMessageAt: -1, updatedAt: -1 });
|
||||||
ConversationSchema.index({ participantIds: 1, isGroup: 1, lastMessageAt: -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 });
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم