diff --git a/schema.graphql b/schema.graphql index 183b9efacd..d5988df432 100644 --- a/schema.graphql +++ b/schema.graphql @@ -206,6 +206,7 @@ type Chat { messages: [ChatMessage] name: String organization: Organization + unseenMessagesByUsers: JSON updatedAt: DateTime! users: [User!]! } @@ -1114,6 +1115,7 @@ type Mutation { likePost(id: ID!): Post login(data: LoginInput!): AuthData! logout: Boolean! + markChatMessagesAsRead(chatId: ID!, userId: ID!): Chat otp(data: OTPInput!): OtpData! recaptcha(data: RecaptchaVerification!): Boolean! refreshToken(refreshToken: String!): ExtendSession! diff --git a/src/models/Chat.ts b/src/models/Chat.ts index 813cd539d4..f5e989055c 100644 --- a/src/models/Chat.ts +++ b/src/models/Chat.ts @@ -20,6 +20,7 @@ export interface InterfaceChat { createdAt: Date; updatedAt: Date; lastMessageId: string; + unseenMessagesByUsers: JSON; } /** @@ -98,9 +99,13 @@ const chatSchema = new Schema( type: String, required: false, }, + unseenMessagesByUsers: { + type: JSON, + required: true, + }, }, { - timestamps: true, + timestamps: false, }, ); diff --git a/src/resolvers/Mutation/createChat.ts b/src/resolvers/Mutation/createChat.ts index 650707cb44..b273718137 100644 --- a/src/resolvers/Mutation/createChat.ts +++ b/src/resolvers/Mutation/createChat.ts @@ -36,10 +36,6 @@ export const createChat: MutationResolvers["createChat"] = async ( } } - // const userExists = (await User.exists({ - // _id: { $in: args.data.userIds }, - // })) as unknown as string[]; - const userExists = await User.find({ _id: { $in: args.data.userIds }, }).lean(); @@ -54,6 +50,13 @@ export const createChat: MutationResolvers["createChat"] = async ( const now = new Date(); + const unseenMessagesByUsers = JSON.stringify( + args.data.userIds.reduce((unseenMessages: Record, user) => { + unseenMessages[user] = 0; + return unseenMessages; + }, {}), + ); + const chatPayload = args.data.isGroup ? { isGroup: args.data.isGroup, @@ -65,6 +68,7 @@ export const createChat: MutationResolvers["createChat"] = async ( createdAt: now, updatedAt: now, image: args.data.image, + unseenMessagesByUsers, } : { creatorId: context.userId, @@ -72,6 +76,7 @@ export const createChat: MutationResolvers["createChat"] = async ( isGroup: args.data.isGroup, createdAt: now, updatedAt: now, + unseenMessagesByUsers, }; const createdChat = await Chat.create(chatPayload); diff --git a/src/resolvers/Mutation/index.ts b/src/resolvers/Mutation/index.ts index 4c6359e4b4..1a752fa8a2 100644 --- a/src/resolvers/Mutation/index.ts +++ b/src/resolvers/Mutation/index.ts @@ -85,6 +85,7 @@ import { revokeRefreshTokenForUser } from "./revokeRefreshTokenForUser"; import { saveFcmToken } from "./saveFcmToken"; import { sendMembershipRequest } from "./sendMembershipRequest"; import { sendMessageToChat } from "./sendMessageToChat"; +import { markChatMessagesAsRead } from "./markChatMessagesAsRead"; import { signUp } from "./signUp"; import { togglePostPin } from "./togglePostPin"; import { unassignUserTag } from "./unassignUserTag"; @@ -237,4 +238,5 @@ export const Mutation: MutationResolvers = { updateFundraisingCampaignPledge, createFundraisingCampaignPledge, removeFundraisingCampaignPledge, + markChatMessagesAsRead, }; diff --git a/src/resolvers/Mutation/markChatMessagesAsRead.ts b/src/resolvers/Mutation/markChatMessagesAsRead.ts new file mode 100644 index 0000000000..dc5840dbab --- /dev/null +++ b/src/resolvers/Mutation/markChatMessagesAsRead.ts @@ -0,0 +1,62 @@ +import type { MutationResolvers } from "../../types/generatedGraphQLTypes"; +import { errors, requestContext } from "../../libraries"; +import { Chat, User } from "../../models"; +import { CHAT_NOT_FOUND_ERROR, USER_NOT_FOUND_ERROR } from "../../constants"; +/** + /** + * This function marks all messages as read for the current user in a chat. + * @param _parent - parent of current request + * @param args - payload provided with the request + * @param context - context of entire application + * @remarks The following checks are done: + * 1. If the direct chat exists. + * 2. If the user exists + * @returns Updated chat object. + */ +export const markChatMessagesAsRead: MutationResolvers["markChatMessagesAsRead"] = + async (_parent, args, context) => { + const chat = await Chat.findOne({ + _id: args.chatId, + }).lean(); + + if (!chat) { + throw new errors.NotFoundError( + requestContext.translate(CHAT_NOT_FOUND_ERROR.MESSAGE), + CHAT_NOT_FOUND_ERROR.CODE, + CHAT_NOT_FOUND_ERROR.PARAM, + ); + } + + const currentUserExists = await User.exists({ + _id: context.userId, + }).lean(); + + if (!currentUserExists) { + throw new errors.NotFoundError( + requestContext.translate(USER_NOT_FOUND_ERROR.MESSAGE), + USER_NOT_FOUND_ERROR.CODE, + USER_NOT_FOUND_ERROR.PARAM, + ); + } + + const unseenMessagesByUsers = JSON.parse( + chat.unseenMessagesByUsers as unknown as string, + ); + + if (unseenMessagesByUsers[context.userId] !== undefined) { + unseenMessagesByUsers[context.userId] = 0; + } + + const updatedChat = await Chat.findByIdAndUpdate( + { + _id: chat._id, + }, + { + $set: { + unseenMessagesByUsers: JSON.stringify(unseenMessagesByUsers), + }, + }, + ); + + return updatedChat; + }; diff --git a/src/resolvers/Mutation/sendMessageToChat.ts b/src/resolvers/Mutation/sendMessageToChat.ts index a5c7e83558..93668227e1 100644 --- a/src/resolvers/Mutation/sendMessageToChat.ts +++ b/src/resolvers/Mutation/sendMessageToChat.ts @@ -52,6 +52,17 @@ export const sendMessageToChat: MutationResolvers["sendMessageToChat"] = async ( updatedAt: now, }); + const unseenMessagesByUsers = JSON.parse( + chat.unseenMessagesByUsers as unknown as string, + ); + + Object.keys(unseenMessagesByUsers).map((user: string) => { + if (user !== context.userId) { + console.log("user", user, context.userId); + unseenMessagesByUsers[user] += 1; + } + }); + // add createdDirectChatMessage to directChat await Chat.updateOne( { @@ -61,6 +72,10 @@ export const sendMessageToChat: MutationResolvers["sendMessageToChat"] = async ( $push: { messages: createdChatMessage._id, }, + $set: { + unseenMessagesByUsers: JSON.stringify(unseenMessagesByUsers), + updatedAt: now, + }, }, ); diff --git a/src/resolvers/Query/chatsByUserId.ts b/src/resolvers/Query/chatsByUserId.ts index 57945ad2f6..ed11bedef0 100644 --- a/src/resolvers/Query/chatsByUserId.ts +++ b/src/resolvers/Query/chatsByUserId.ts @@ -1,5 +1,6 @@ import type { QueryResolvers } from "../../types/generatedGraphQLTypes"; import { Chat } from "../../models"; +import type { SortOrder } from "mongoose"; /** * This query will fetch all the Chats for the current user from the database. * @param _parent- @@ -13,11 +14,20 @@ export const chatsByUserId: QueryResolvers["chatsByUserId"] = async ( _parent, args, ) => { + const sort = { + updatedAt: -1, + } as + | string + | { [key: string]: SortOrder | { $meta: unknown } } + | [string, SortOrder][] + | null + | undefined; + const chats = await Chat.find({ users: args.id, - }).lean(); - - console.log(chats); + }) + .sort(sort) + .lean(); return chats; }; diff --git a/src/typeDefs/mutations.ts b/src/typeDefs/mutations.ts index b022b72be1..e9aacca0f3 100644 --- a/src/typeDefs/mutations.ts +++ b/src/typeDefs/mutations.ts @@ -250,6 +250,8 @@ export const mutations = gql` replyTo: ID ): ChatMessage! @auth + markChatMessagesAsRead(chatId: ID!, userId: ID!): Chat @auth + signUp(data: UserInput!, file: String): AuthData! togglePostPin(id: ID!, title: String): Post! @auth diff --git a/src/typeDefs/types.ts b/src/typeDefs/types.ts index d6bf4952b7..55e28e1a11 100644 --- a/src/typeDefs/types.ts +++ b/src/typeDefs/types.ts @@ -742,6 +742,7 @@ export const types = gql` admins: [User] lastMessageId: String image: String + unseenMessagesByUsers: JSON } type ChatMessage { diff --git a/src/types/generatedGraphQLTypes.ts b/src/types/generatedGraphQLTypes.ts index 13e9ba20ba..680f458241 100644 --- a/src/types/generatedGraphQLTypes.ts +++ b/src/types/generatedGraphQLTypes.ts @@ -279,6 +279,7 @@ export type Chat = { messages?: Maybe>>; name?: Maybe; organization?: Maybe; + unseenMessagesByUsers?: Maybe; updatedAt: Scalars['DateTime']['output']; users: Array; }; @@ -1200,6 +1201,7 @@ export type Mutation = { likePost?: Maybe; login: AuthData; logout: Scalars['Boolean']['output']; + markChatMessagesAsRead?: Maybe; otp: OtpData; recaptcha: Scalars['Boolean']['output']; refreshToken: ExtendSession; @@ -1566,6 +1568,12 @@ export type MutationLoginArgs = { }; +export type MutationMarkChatMessagesAsReadArgs = { + chatId: Scalars['ID']['input']; + userId: Scalars['ID']['input']; +}; + + export type MutationOtpArgs = { data: OtpInput; }; @@ -3818,6 +3826,7 @@ export type ChatResolvers>>, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; organization?: Resolver, ParentType, ContextType>; + unseenMessagesByUsers?: Resolver, ParentType, ContextType>; updatedAt?: Resolver; users?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -4278,6 +4287,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; login?: Resolver>; logout?: Resolver; + markChatMessagesAsRead?: Resolver, ParentType, ContextType, RequireFields>; otp?: Resolver>; recaptcha?: Resolver>; refreshToken?: Resolver>; diff --git a/tests/helpers/chat.ts b/tests/helpers/chat.ts index b3fa7b468a..f748a7da7b 100644 --- a/tests/helpers/chat.ts +++ b/tests/helpers/chat.ts @@ -26,6 +26,9 @@ export const createTestChat = async (): Promise< createdAt: new Date(), updatedAt: new Date(), admins: [testUser._id], + unseenMessagesByUsers: JSON.stringify({ + [testUser._id]: 0, + }), }); return [testUser, testOrganization, testChat]; diff --git a/tests/resolvers/Mutation/markChatMessagesAsRead.spec.ts b/tests/resolvers/Mutation/markChatMessagesAsRead.spec.ts new file mode 100644 index 0000000000..98ecbec727 --- /dev/null +++ b/tests/resolvers/Mutation/markChatMessagesAsRead.spec.ts @@ -0,0 +1,170 @@ +import "dotenv/config"; +import type { Document } from "mongoose"; +import type mongoose from "mongoose"; +import { Types } from "mongoose"; +import type { InterfaceChat } from "../../../src/models"; +import { User, Organization, Chat } from "../../../src/models"; +import type { MutationMarkChatMessagesAsReadArgs } from "../../../src/types/generatedGraphQLTypes"; +import { connect, disconnect } from "../../helpers/db"; + +import { + CHAT_NOT_FOUND_ERROR, + USER_NOT_FOUND_ERROR, +} from "../../../src/constants"; +import { + beforeAll, + afterAll, + afterEach, + describe, + it, + expect, + vi, +} from "vitest"; +import { createTestUserFunc } from "../../helpers/user"; +import type { TestUserType } from "../../helpers/userAndOrg"; + +let MONGOOSE_INSTANCE: typeof mongoose; +let testUsers: TestUserType[]; +let testChat: InterfaceChat & Document; + +beforeAll(async () => { + MONGOOSE_INSTANCE = await connect(); + const tempUser1 = await createTestUserFunc(); + const tempUser2 = await createTestUserFunc(); + testUsers = [tempUser1, tempUser2]; + + const testOrganization = await Organization.create({ + name: "name", + description: "description", + isPublic: true, + creatorId: testUsers[0]?._id, + admins: [testUsers[0]?._id], + members: [testUsers[0]?._id], + visibleInSearch: true, + }); + + await User.updateOne( + { + _id: testUsers[0]?._id, + }, + { + $set: { + createdOrganizations: [testOrganization._id], + adminFor: [testOrganization._id], + joinedOrganizations: [testOrganization._id], + }, + }, + ); + + testChat = await Chat.create({ + name: "Chat", + creatorId: testUsers[0]?._id, + organization: testOrganization._id, + users: [testUsers[0]?._id, testUsers[1]?._id], + isGroup: false, + createdAt: "23456789", + updatedAt: "23456789", + unseenMessagesByUsers: JSON.stringify({ + [testUsers[0]?._id]: 0, + [testUsers[1]?._id]: 0, + }), + }); +}); + +afterAll(async () => { + await disconnect(MONGOOSE_INSTANCE); +}); + +describe("resolvers -> Mutation -> markChatMessagesAsRead", () => { + afterEach(async () => { + vi.doUnmock("../../../src/constants"); + vi.resetModules(); + }); + + it(`throws NotFoundError if no directChat exists with _id === args.chatId`, async () => { + const { requestContext } = await import("../../../src/libraries"); + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => message); + try { + const args: MutationMarkChatMessagesAsReadArgs = { + chatId: new Types.ObjectId().toString(), + userId: testUsers[0]?._id.toString(), + }; + + const context = { userId: testUsers[0]?.id }; + + const { markChatMessagesAsRead: markChatMessagesAsReadResolver } = + await import("../../../src/resolvers/Mutation/markChatMessagesAsRead"); + + await markChatMessagesAsReadResolver?.({}, args, context); + } catch (error: unknown) { + expect(spy).toBeCalledWith(CHAT_NOT_FOUND_ERROR.MESSAGE); + expect((error as Error).message).toEqual(CHAT_NOT_FOUND_ERROR.MESSAGE); + } + }); + it(`throws NotFoundError if current user with _id === context.userId does not exist`, async () => { + const { requestContext } = await import("../../../src/libraries"); + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => message); + try { + const args: MutationMarkChatMessagesAsReadArgs = { + chatId: testChat.id, + userId: testUsers[0]?.id, + }; + + const context = { + userId: new Types.ObjectId().toString(), + }; + + const { markChatMessagesAsRead: markChatMessagesAsReadResolver } = + await import("../../../src/resolvers/Mutation/markChatMessagesAsRead"); + + await markChatMessagesAsReadResolver?.({}, args, context); + } catch (error: unknown) { + expect(spy).toBeCalledWith(USER_NOT_FOUND_ERROR.MESSAGE); + expect((error as Error).message).toEqual(USER_NOT_FOUND_ERROR.MESSAGE); + } + }); + + it(`creates the directChatMessage and returns it`, async () => { + await Chat.updateOne( + { + _id: testChat._id, + }, + { + $push: { + users: testUsers[0]?._id, + }, + }, + ); + + const args: MutationMarkChatMessagesAsReadArgs = { + chatId: testChat.id, + userId: testUsers[0]?.id, + }; + + const context = { + userId: testUsers[0]?.id, + }; + + const { markChatMessagesAsRead: markChatMessagesAsReadResolver } = + await import("../../../src/resolvers/Mutation/markChatMessagesAsRead"); + + const sendMessageToChatPayload = await markChatMessagesAsReadResolver?.( + {}, + args, + context, + ); + + expect(sendMessageToChatPayload).toEqual( + expect.objectContaining({ + unseenMessagesByUsers: JSON.stringify({ + [testUsers[0]?._id]: 0, + [testUsers[1]?._id]: 0, + }), + }), + ); + }); +}); diff --git a/tests/resolvers/Mutation/sendMessageToChat.spec.ts b/tests/resolvers/Mutation/sendMessageToChat.spec.ts index ace1591c82..ec032b4d68 100644 --- a/tests/resolvers/Mutation/sendMessageToChat.spec.ts +++ b/tests/resolvers/Mutation/sendMessageToChat.spec.ts @@ -65,6 +65,10 @@ beforeAll(async () => { isGroup: false, createdAt: "23456789", updatedAt: "23456789", + unseenMessagesByUsers: JSON.stringify({ + [testUsers[0]?._id]: 0, + [testUsers[1]?._id]: 0, + }), }); });