diff --git a/schema.graphql b/schema.graphql index 98a4c91042..183b9efacd 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1068,6 +1068,7 @@ type Mutation { addUserCustomData(dataName: String!, dataValue: Any!, organizationId: ID!): UserCustomData! addUserImage(file: String!): User! addUserToUserFamily(familyId: ID!, userId: ID!): UserFamily! + assignToUserTags(input: TagActionsInput!): UserTag assignUserTag(input: ToggleUserTagAssignInput!): User blockPluginCreationBySuperadmin(blockUser: Boolean!, userId: ID!): AppUserProfile! blockUser(organizationId: ID!, userId: ID!): User! @@ -1129,6 +1130,7 @@ type Mutation { removeEventAttendee(data: EventAttendeeInput!): User! removeEventVolunteer(id: ID!): EventVolunteer! removeEventVolunteerGroup(id: ID!): EventVolunteerGroup! + removeFromUserTags(input: TagActionsInput!): UserTag removeFundraisingCampaignPledge(id: ID!): FundraisingCampaignPledge! removeMember(data: UserAndOrganizationInput!): Organization! removeOrganization(id: ID!): UserData! @@ -1609,6 +1611,11 @@ type Subscription { onPluginUpdate: Plugin } +input TagActionsInput { + currentTagId: ID! + selectedTagIds: [ID!]! +} + scalar Time input ToggleUserTagAssignInput { diff --git a/src/resolvers/Mutation/assignToUserTags.ts b/src/resolvers/Mutation/assignToUserTags.ts new file mode 100644 index 0000000000..0cc3e40783 --- /dev/null +++ b/src/resolvers/Mutation/assignToUserTags.ts @@ -0,0 +1,168 @@ +import { Types } from "mongoose"; +import { + TAG_NOT_FOUND, + USER_NOT_FOUND_ERROR, + USER_NOT_AUTHORIZED_ERROR, +} from "../../constants"; +import { errors, requestContext } from "../../libraries"; +import type { + InterfaceAppUserProfile, + InterfaceOrganizationTagUser, + InterfaceUser, +} from "../../models"; +import { + AppUserProfile, + OrganizationTagUser, + TagUser, + User, +} from "../../models"; +import { cacheAppUserProfile } from "../../services/AppUserProfileCache/cacheAppUserProfile"; +import { findAppUserProfileCache } from "../../services/AppUserProfileCache/findAppUserProfileCache"; +import { cacheUsers } from "../../services/UserCache/cacheUser"; +import { findUserInCache } from "../../services/UserCache/findUserInCache"; +import type { MutationResolvers } from "../../types/generatedGraphQLTypes"; + +/** + * This function enables an admin to assign multiple tags to users with a specified tag. + * @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 current user exists and has a profile. + * 2. If the current user is an admin for the organization of the tags. + * 3. If the currentTagId exists and the selected tags exist. + * 4. Assign the tags to users who have the currentTagId. + * @returns Array of tags that were assigned to users. + */ +export const assignToUserTags: MutationResolvers["assignToUserTags"] = async ( + _parent, + args, + context, +) => { + let currentUser: InterfaceUser | null; + const userFoundInCache = await findUserInCache([context.userId]); + currentUser = userFoundInCache[0]; + if (currentUser === null) { + currentUser = await User.findOne({ + _id: context.userId, + }).lean(); + if (currentUser !== null) { + await cacheUsers([currentUser]); + } + } + + // Checks whether the currentUser exists. + if (!currentUser) { + throw new errors.NotFoundError( + requestContext.translate(USER_NOT_FOUND_ERROR.MESSAGE), + USER_NOT_FOUND_ERROR.CODE, + USER_NOT_FOUND_ERROR.PARAM, + ); + } + + let currentUserAppProfile: InterfaceAppUserProfile | null; + + const appUserProfileFoundInCache = await findAppUserProfileCache([ + currentUser.appUserProfileId.toString(), + ]); + + currentUserAppProfile = appUserProfileFoundInCache[0]; + if (currentUserAppProfile === null) { + currentUserAppProfile = await AppUserProfile.findOne({ + userId: currentUser._id, + }).lean(); + if (currentUserAppProfile !== null) { + await cacheAppUserProfile([currentUserAppProfile]); + } + } + + if (!currentUserAppProfile) { + throw new errors.UnauthorizedError( + requestContext.translate(USER_NOT_AUTHORIZED_ERROR.MESSAGE), + USER_NOT_AUTHORIZED_ERROR.CODE, + USER_NOT_AUTHORIZED_ERROR.PARAM, + ); + } + + // Get the current tag object + const currentTag = await OrganizationTagUser.findOne({ + _id: args.input.currentTagId, + }).lean(); + + if (!currentTag) { + throw new errors.NotFoundError( + requestContext.translate(TAG_NOT_FOUND.MESSAGE), + TAG_NOT_FOUND.CODE, + TAG_NOT_FOUND.PARAM, + ); + } + + // Boolean to determine whether user is an admin of the organization of the current tag. + const currentUserIsOrganizationAdmin = currentUserAppProfile.adminFor.some( + (orgId) => orgId?.toString() === currentTag.organizationId.toString(), + ); + + if (!(currentUserIsOrganizationAdmin || currentUserAppProfile.isSuperAdmin)) { + throw new errors.UnauthorizedError( + requestContext.translate(USER_NOT_AUTHORIZED_ERROR.MESSAGE), + USER_NOT_AUTHORIZED_ERROR.CODE, + USER_NOT_AUTHORIZED_ERROR.PARAM, + ); + } + + // Find selected tags & all users tagged with the current tag + const [selectedTags, usersWithCurrentTag] = await Promise.all([ + OrganizationTagUser.find({ + _id: { $in: args.input.selectedTagIds }, + }).lean(), + TagUser.find({ tagId: currentTag._id }).lean(), + ]); + + const userIdsWithCurrentTag = usersWithCurrentTag.map( + (userTag) => userTag.userId, + ); + + // Check if all requested tags were found + if (selectedTags.length !== args.input.selectedTagIds.length) { + throw new errors.NotFoundError( + requestContext.translate(TAG_NOT_FOUND.MESSAGE), + TAG_NOT_FOUND.CODE, + TAG_NOT_FOUND.PARAM, + ); + } + + // Find and assign ancestor tags + const allTagsToAssign = new Set(); + for (const tag of selectedTags) { + let currentTagToProcess: InterfaceOrganizationTagUser | null = tag; + while (currentTagToProcess) { + allTagsToAssign.add(currentTagToProcess._id.toString()); + if (currentTagToProcess.parentTagId) { + const parentTag: InterfaceOrganizationTagUser | null = + await OrganizationTagUser.findOne({ + _id: currentTagToProcess.parentTagId, + }).lean(); + currentTagToProcess = parentTag || null; + } else { + currentTagToProcess = null; + } + } + } + + const tagUserDocs = userIdsWithCurrentTag.flatMap((userId) => + Array.from(allTagsToAssign).map((tagId) => ({ + updateOne: { + filter: { userId, tagId: new Types.ObjectId(tagId) }, + update: { $setOnInsert: { userId, tagId: new Types.ObjectId(tagId) } }, + upsert: true, + setDefaultsOnInsert: true, + }, + })), + ); + + if (tagUserDocs.length > 0) { + await TagUser.bulkWrite(tagUserDocs); + } + + return currentTag; +}; diff --git a/src/resolvers/Mutation/index.ts b/src/resolvers/Mutation/index.ts index 7afa24753c..4c6359e4b4 100644 --- a/src/resolvers/Mutation/index.ts +++ b/src/resolvers/Mutation/index.ts @@ -10,6 +10,7 @@ import { addUserImage } from "./addUserImage"; import { addUserToUserFamily } from "./addUserToUserFamily"; import { addPeopleToUserTag } from "./addPeopleToUserTag"; import { assignUserTag } from "./assignUserTag"; +import { assignToUserTags } from "./assignToUserTags"; import { blockPluginCreationBySuperadmin } from "./blockPluginCreationBySuperadmin"; import { blockUser } from "./blockUser"; import { cancelMembershipRequest } from "./cancelMembershipRequest"; @@ -78,6 +79,7 @@ import { removeUserFamily } from "./removeUserFamily"; import { removeUserFromUserFamily } from "./removeUserFromUserFamily"; import { removeUserImage } from "./removeUserImage"; import { removeUserTag } from "./removeUserTag"; +import { removeFromUserTags } from "./removeFromUserTags"; import { resetCommunity } from "./resetCommunity"; import { revokeRefreshTokenForUser } from "./revokeRefreshTokenForUser"; import { saveFcmToken } from "./saveFcmToken"; @@ -127,10 +129,11 @@ export const Mutation: MutationResolvers = { addUserImage, addUserToUserFamily, addPeopleToUserTag, + assignUserTag, + assignToUserTags, removeUserFamily, removeUserFromUserFamily, createUserFamily, - assignUserTag, blockPluginCreationBySuperadmin, blockUser, cancelMembershipRequest, @@ -197,6 +200,7 @@ export const Mutation: MutationResolvers = { removeUserCustomData, removeUserImage, removeUserTag, + removeFromUserTags, resetCommunity, revokeRefreshTokenForUser, saveFcmToken, diff --git a/src/resolvers/Mutation/removeFromUserTags.ts b/src/resolvers/Mutation/removeFromUserTags.ts new file mode 100644 index 0000000000..0420f5fbb5 --- /dev/null +++ b/src/resolvers/Mutation/removeFromUserTags.ts @@ -0,0 +1,165 @@ +import { Types } from "mongoose"; +import { + TAG_NOT_FOUND, + USER_NOT_FOUND_ERROR, + USER_NOT_AUTHORIZED_ERROR, +} from "../../constants"; +import { errors, requestContext } from "../../libraries"; +import type { InterfaceAppUserProfile, InterfaceUser } from "../../models"; +import { + AppUserProfile, + OrganizationTagUser, + TagUser, + User, +} from "../../models"; +import { cacheAppUserProfile } from "../../services/AppUserProfileCache/cacheAppUserProfile"; +import { findAppUserProfileCache } from "../../services/AppUserProfileCache/findAppUserProfileCache"; +import { cacheUsers } from "../../services/UserCache/cacheUser"; +import { findUserInCache } from "../../services/UserCache/findUserInCache"; +import type { MutationResolvers } from "../../types/generatedGraphQLTypes"; + +/** + * This function enables an admin to remove multiple tags from users with a specified tag. + * @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 current user exists and has a profile. + * 2. If the current user is an admin for the organization of the tags. + * 3. If the currentTagId exists and the selected tags exist. + * 4. Remove the tags from users who have the currentTagId. + * @returns Array of tags that were removed from users. + */ +export const removeFromUserTags: MutationResolvers["removeFromUserTags"] = + async (_parent, args, context) => { + let currentUser: InterfaceUser | null; + const userFoundInCache = await findUserInCache([context.userId]); + currentUser = userFoundInCache[0]; + if (currentUser === null) { + currentUser = await User.findOne({ + _id: context.userId, + }).lean(); + if (currentUser !== null) { + await cacheUsers([currentUser]); + } + } + + // Checks whether the currentUser exists. + if (!currentUser) { + throw new errors.NotFoundError( + requestContext.translate(USER_NOT_FOUND_ERROR.MESSAGE), + USER_NOT_FOUND_ERROR.CODE, + USER_NOT_FOUND_ERROR.PARAM, + ); + } + + let currentUserAppProfile: InterfaceAppUserProfile | null; + + const appUserProfileFoundInCache = await findAppUserProfileCache([ + currentUser.appUserProfileId.toString(), + ]); + + currentUserAppProfile = appUserProfileFoundInCache[0]; + if (currentUserAppProfile === null) { + currentUserAppProfile = await AppUserProfile.findOne({ + userId: currentUser._id, + }).lean(); + if (currentUserAppProfile !== null) { + await cacheAppUserProfile([currentUserAppProfile]); + } + } + + if (!currentUserAppProfile) { + throw new errors.UnauthorizedError( + requestContext.translate(USER_NOT_AUTHORIZED_ERROR.MESSAGE), + USER_NOT_AUTHORIZED_ERROR.CODE, + USER_NOT_AUTHORIZED_ERROR.PARAM, + ); + } + + // Get the current tag object + const currentTag = await OrganizationTagUser.findOne({ + _id: args.input.currentTagId, + }).lean(); + + if (!currentTag) { + throw new errors.NotFoundError( + requestContext.translate(TAG_NOT_FOUND.MESSAGE), + TAG_NOT_FOUND.CODE, + TAG_NOT_FOUND.PARAM, + ); + } + + // Boolean to determine whether user is an admin of the organization of the current tag. + const currentUserIsOrganizationAdmin = currentUserAppProfile.adminFor.some( + (orgId) => orgId?.toString() === currentTag.organizationId.toString(), + ); + + if ( + !(currentUserIsOrganizationAdmin || currentUserAppProfile.isSuperAdmin) + ) { + throw new errors.UnauthorizedError( + requestContext.translate(USER_NOT_AUTHORIZED_ERROR.MESSAGE), + USER_NOT_AUTHORIZED_ERROR.CODE, + USER_NOT_AUTHORIZED_ERROR.PARAM, + ); + } + + // Find selected tags & all users tagged with the current tag + const [selectedTags, usersWithCurrentTag] = await Promise.all([ + OrganizationTagUser.find({ + _id: { $in: args.input.selectedTagIds }, + }).lean(), + TagUser.find({ tagId: currentTag._id }).lean(), + ]); + + const userIdsWithCurrentTag = usersWithCurrentTag.map( + (userTag) => userTag.userId, + ); + + if (selectedTags.length !== args.input.selectedTagIds.length) { + throw new errors.NotFoundError( + requestContext.translate(TAG_NOT_FOUND.MESSAGE), + TAG_NOT_FOUND.CODE, + TAG_NOT_FOUND.PARAM, + ); + } + + // Get all descendant tags of the selected tags (including the selected tags themselves) + const allTagsToRemove = new Set(); + let currentParents = selectedTags.map((tag) => tag._id.toString()); + + while (currentParents.length > 0) { + // Add the current level of tags to the set + for (const parentId of currentParents) { + allTagsToRemove.add(parentId); + } + + // Find the next level of child tags + const childTags = await OrganizationTagUser.find( + { + parentTagId: { $in: currentParents }, + }, + { _id: 1 }, + ).lean(); + + // Update currentParents with the next level of children + currentParents = childTags.map((tag) => tag._id.toString()); + } + + // Now allTagsToRemove contains all descendants of the selected tags + + const tagUserDocs = userIdsWithCurrentTag.flatMap((userId) => + Array.from(allTagsToRemove).map((tagId) => ({ + deleteOne: { + filter: { userId, tagId: new Types.ObjectId(tagId) }, + }, + })), + ); + + if (tagUserDocs.length > 0) { + await TagUser.bulkWrite(tagUserDocs); + } + + return currentTag; + }; diff --git a/src/typeDefs/inputs.ts b/src/typeDefs/inputs.ts index 76c3becf51..06888ea4ee 100644 --- a/src/typeDefs/inputs.ts +++ b/src/typeDefs/inputs.ts @@ -437,6 +437,11 @@ export const inputs = gql` tagId: ID! } + input TagActionsInput { + currentTagId: ID! + selectedTagIds: [ID!]! + } + input UpdateActionItemInput { assigneeId: ID preCompletionNotes: String diff --git a/src/typeDefs/mutations.ts b/src/typeDefs/mutations.ts index 619845aa92..b022b72be1 100644 --- a/src/typeDefs/mutations.ts +++ b/src/typeDefs/mutations.ts @@ -39,6 +39,10 @@ export const mutations = gql` @auth @role(requires: ADMIN) + assignToUserTags(input: TagActionsInput!): UserTag + @auth + @role(requires: ADMIN) + removeUserFromUserFamily(userId: ID!, familyId: ID!): UserFamily! @auth removeUserFamily(familyId: ID!): UserFamily! @auth @@ -228,6 +232,10 @@ export const mutations = gql` removeUserImage: User! @auth + removeFromUserTags(input: TagActionsInput!): UserTag + @auth + @role(requires: ADMIN) + resetCommunity: Boolean! @auth @role(requires: SUPERADMIN) revokeRefreshTokenForUser: Boolean! @auth diff --git a/src/types/generatedGraphQLTypes.ts b/src/types/generatedGraphQLTypes.ts index 8d807f00b5..13e9ba20ba 100644 --- a/src/types/generatedGraphQLTypes.ts +++ b/src/types/generatedGraphQLTypes.ts @@ -1154,6 +1154,7 @@ export type Mutation = { addUserCustomData: UserCustomData; addUserImage: User; addUserToUserFamily: UserFamily; + assignToUserTags?: Maybe; assignUserTag?: Maybe; blockPluginCreationBySuperadmin: AppUserProfile; blockUser: User; @@ -1215,6 +1216,7 @@ export type Mutation = { removeEventAttendee: User; removeEventVolunteer: EventVolunteer; removeEventVolunteerGroup: EventVolunteerGroup; + removeFromUserTags?: Maybe; removeFundraisingCampaignPledge: FundraisingCampaignPledge; removeMember: Organization; removeOrganization: UserData; @@ -1327,6 +1329,11 @@ export type MutationAddUserToUserFamilyArgs = { }; +export type MutationAssignToUserTagsArgs = { + input: TagActionsInput; +}; + + export type MutationAssignUserTagArgs = { input: ToggleUserTagAssignInput; }; @@ -1640,6 +1647,11 @@ export type MutationRemoveEventVolunteerGroupArgs = { }; +export type MutationRemoveFromUserTagsArgs = { + input: TagActionsInput; +}; + + export type MutationRemoveFundraisingCampaignPledgeArgs = { id: Scalars['ID']['input']; }; @@ -2665,6 +2677,11 @@ export type SubscriptionMessageSentToChatArgs = { userId: Scalars['ID']['input']; }; +export type TagActionsInput = { + currentTagId: Scalars['ID']['input']; + selectedTagIds: Array; +}; + export type ToggleUserTagAssignInput = { tagId: Scalars['ID']['input']; userId: Scalars['ID']['input']; @@ -3382,6 +3399,7 @@ export type ResolversTypes = { Status: Status; String: ResolverTypeWrapper; Subscription: ResolverTypeWrapper<{}>; + TagActionsInput: TagActionsInput; Time: ResolverTypeWrapper; ToggleUserTagAssignInput: ToggleUserTagAssignInput; Translation: ResolverTypeWrapper; @@ -3576,6 +3594,7 @@ export type ResolversParentTypes = { SocialMediaUrlsInput: SocialMediaUrlsInput; String: Scalars['String']['output']; Subscription: {}; + TagActionsInput: TagActionsInput; Time: Scalars['Time']['output']; ToggleUserTagAssignInput: ToggleUserTagAssignInput; Translation: Translation; @@ -4213,6 +4232,7 @@ export type MutationResolvers>; addUserImage?: Resolver>; addUserToUserFamily?: Resolver>; + assignToUserTags?: Resolver, ParentType, ContextType, RequireFields>; assignUserTag?: Resolver, ParentType, ContextType, RequireFields>; blockPluginCreationBySuperadmin?: Resolver>; blockUser?: Resolver>; @@ -4274,6 +4294,7 @@ export type MutationResolvers>; removeEventVolunteer?: Resolver>; removeEventVolunteerGroup?: Resolver>; + removeFromUserTags?: Resolver, ParentType, ContextType, RequireFields>; removeFundraisingCampaignPledge?: Resolver>; removeMember?: Resolver>; removeOrganization?: Resolver>; diff --git a/tests/resolvers/Mutation/assignToUserTags.spec.ts b/tests/resolvers/Mutation/assignToUserTags.spec.ts new file mode 100644 index 0000000000..9486c2db7e --- /dev/null +++ b/tests/resolvers/Mutation/assignToUserTags.spec.ts @@ -0,0 +1,318 @@ +import "dotenv/config"; +import type mongoose from "mongoose"; +import { Types } from "mongoose"; +import type { MutationAssignToUserTagsArgs } from "../../../src/types/generatedGraphQLTypes"; +import { connect, disconnect } from "../../helpers/db"; + +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { + TAG_NOT_FOUND, + USER_NOT_AUTHORIZED_ERROR, + USER_NOT_FOUND_ERROR, +} from "../../../src/constants"; +import { + AppUserProfile, + OrganizationTagUser, + TagUser, + User, +} from "../../../src/models"; +import type { TestUserTagType } from "../../helpers/tags"; +import { + createRootTagsWithOrg, + createTwoLevelTagsWithOrg, +} from "../../helpers/tags"; +import type { + TestOrganizationType, + TestUserType, +} from "../../helpers/userAndOrg"; +import { createTestUser } from "../../helpers/userAndOrg"; + +let MONGOOSE_INSTANCE: typeof mongoose; + +let adminUser: TestUserType; +let adminUser2: TestUserType; +let testTag2: TestUserTagType; +let testTag3: TestUserTagType; +let testTag: TestUserTagType; +let testSubTag1: TestUserTagType; +let testOrg1: TestOrganizationType; +let testOrg2: TestOrganizationType; +let randomUser1: TestUserType; +let randomUser2: TestUserType; +let randomUser3: TestUserType; + +beforeAll(async () => { + MONGOOSE_INSTANCE = await connect(); + [adminUser, testOrg1, [testTag, testSubTag1]] = + await createTwoLevelTagsWithOrg(); + [adminUser2, testOrg2, [testTag2, testTag3]] = await createRootTagsWithOrg(2); + randomUser1 = await createTestUser(); + randomUser2 = await createTestUser(); + randomUser3 = await createTestUser(); +}); + +afterAll(async () => { + await disconnect(MONGOOSE_INSTANCE); +}); + +describe("resolvers -> Mutation -> assignToUserTags", () => { + afterEach(() => { + vi.doUnmock("../../../src/constants"); + vi.resetModules(); + vi.resetAllMocks(); + }); + + it(`throws NotFoundError if no user exists with _id === context.userId `, async () => { + await testErrorScenario({ + args: { + input: { + selectedTagIds: [testTag?._id.toString() ?? ""], + currentTagId: testTag?._id.toString() ?? "", + }, + }, + context: { userId: new Types.ObjectId().toString() }, + expectedError: USER_NOT_FOUND_ERROR.MESSAGE, + }); + }); + + it(`throws NotFoundError if no tag exists with _id === args.input.currentTagId `, async () => { + await testErrorScenario({ + args: { + input: { + selectedTagIds: [testTag?._id.toString() ?? ""], + currentTagId: new Types.ObjectId().toString(), + }, + }, + context: { userId: adminUser?._id }, + expectedError: TAG_NOT_FOUND.MESSAGE, + }); + }); + + it(`throws Not Authorized Error if the current user is not a superadmin or admin of the organization `, async () => { + await testErrorScenario({ + args: { + input: { + selectedTagIds: [testTag?._id.toString() ?? ""], + currentTagId: testTag?._id.toString() ?? "", + }, + }, + context: { userId: randomUser1?._id }, + expectedError: USER_NOT_AUTHORIZED_ERROR.MESSAGE, + }); + }); + + it(`throws NotFoundError if one of the selected tags doesn't exist`, async () => { + await testErrorScenario({ + args: { + input: { + selectedTagIds: [ + testTag?._id.toString() ?? "", + new Types.ObjectId().toString(), + ], + currentTagId: testTag?._id.toString() ?? "", + }, + }, + context: { userId: adminUser?._id }, + expectedError: TAG_NOT_FOUND.MESSAGE, + }); + }); + + it("throws error if user does not have appUserProfile", async () => { + const temp = await createTestUser(); + await AppUserProfile.deleteOne({ + userId: temp?._id, + }); + + await testErrorScenario({ + args: { + input: { + selectedTagIds: [testTag?._id.toString() ?? ""], + currentTagId: testTag?._id.toString() ?? "", + }, + }, + context: { userId: temp?._id }, + expectedError: USER_NOT_AUTHORIZED_ERROR.MESSAGE, + }); + }); + + it(`Tag assignment should be successful and the tag is returned`, async () => { + // assign testTag2 to random users + await Promise.all([ + User.findOneAndUpdate( + { + _id: randomUser2?._id, + }, + { + joinedOrganizations: testOrg2, + }, + ), + User.findOneAndUpdate( + { + _id: randomUser3?._id, + }, + { + joinedOrganizations: testOrg2, + }, + ), + TagUser.create({ + userId: randomUser2?._id, + tagId: testTag2?._id, + }), + TagUser.create({ + userId: randomUser3?._id, + tagId: testTag2?._id, + }), + ]); + + // now assign them to a new tag with the help of the mutation + const args: MutationAssignToUserTagsArgs = { + input: { + selectedTagIds: [testTag3?._id.toString() ?? ""], + currentTagId: testTag2?._id.toString() ?? "", + }, + }; + + const context = { + userId: adminUser2?._id, + }; + + const { assignToUserTags: assignToUserTagsResolver } = await import( + "../../../src/resolvers/Mutation/assignToUserTags" + ); + + const payload = await assignToUserTagsResolver?.({}, args, context); + + expect(payload?._id.toString()).toEqual(testTag2?._id.toString()); + + const tagAssignedToRandomUser2 = await TagUser.exists({ + tagId: testTag3, + userId: randomUser2?._id, + }); + + const tagAssignedToRandomUser3 = await TagUser.exists({ + tagId: testTag3, + userId: randomUser3?._id, + }); + + expect(tagAssignedToRandomUser2).toBeTruthy(); + expect(tagAssignedToRandomUser3).toBeTruthy(); + }); + + it(`Should assign all the ancestor tags and returns the current tag`, async () => { + // create a new tag with the organization + const newTestTag = await OrganizationTagUser.create({ + name: "newTestTag", + organizationId: testOrg1?._id, + }); + + // assign this new test tag to random users + await Promise.all([ + User.findOneAndUpdate( + { + _id: randomUser2?._id, + }, + { + joinedOrganizations: testOrg1, + }, + ), + User.findOneAndUpdate( + { + _id: randomUser3?._id, + }, + { + joinedOrganizations: testOrg1, + }, + ), + TagUser.create({ + userId: randomUser2?._id, + tagId: newTestTag?._id, + }), + TagUser.create({ + userId: randomUser3?._id, + tagId: newTestTag?._id, + }), + ]); + + // now assign them a new sub tag, which will automatically assign them the parent tag also + const args: MutationAssignToUserTagsArgs = { + input: { + selectedTagIds: [testSubTag1?._id.toString() ?? ""], + currentTagId: newTestTag?._id.toString() ?? "", + }, + }; + const context = { + userId: adminUser?._id, + }; + + const { assignToUserTags: assignToUserTagsResolver } = await import( + "../../../src/resolvers/Mutation/assignToUserTags" + ); + + const payload = await assignToUserTagsResolver?.({}, args, context); + + expect(payload?._id.toString()).toEqual(newTestTag?._id.toString()); + + const subTagAssignedToRandomUser2 = await TagUser.exists({ + tagId: testSubTag1?._id, + userId: randomUser2?._id, + }); + + const subTagAssignedToRandomUser3 = await TagUser.exists({ + tagId: testSubTag1?._id, + userId: randomUser3?._id, + }); + + expect(subTagAssignedToRandomUser2).toBeTruthy(); + expect(subTagAssignedToRandomUser3).toBeTruthy(); + + const ancestorTagAssignedToRandomUser2 = await TagUser.exists({ + tagId: testTag?._id.toString() ?? "", + userId: randomUser2?._id, + }); + + const ancestorTagAssignedToRandomUser3 = await TagUser.exists({ + tagId: testTag?._id.toString() ?? "", + userId: randomUser3?._id, + }); + + expect(ancestorTagAssignedToRandomUser2).toBeTruthy(); + expect(ancestorTagAssignedToRandomUser3).toBeTruthy(); + }); +}); + +const testErrorScenario = async ({ + args, + context, + expectedError, +}: { + args: MutationAssignToUserTagsArgs; + context: { userId: string }; + expectedError: string; +}): Promise => { + const { requestContext } = await import("../../../src/libraries"); + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + try { + const { assignToUserTags: assignToUserTagsResolver } = await import( + "../../../src/resolvers/Mutation/assignToUserTags" + ); + await assignToUserTagsResolver?.({}, args, context); + throw new Error("Expected error was not thrown"); + } catch (error: unknown) { + if (error instanceof Error) { + expect(error.message).toEqual(`Translated ${expectedError}`); + } else { + throw new Error("Unexpected error type"); + } + expect(spy).toHaveBeenLastCalledWith(expectedError); + } +}; diff --git a/tests/resolvers/Mutation/removeFromUserTags.spec.ts b/tests/resolvers/Mutation/removeFromUserTags.spec.ts new file mode 100644 index 0000000000..9ac18b68db --- /dev/null +++ b/tests/resolvers/Mutation/removeFromUserTags.spec.ts @@ -0,0 +1,412 @@ +import "dotenv/config"; +import type mongoose from "mongoose"; +import { Types } from "mongoose"; +import type { + MutationAssignToUserTagsArgs, + MutationRemoveFromUserTagsArgs, +} from "../../../src/types/generatedGraphQLTypes"; +import { connect, disconnect } from "../../helpers/db"; + +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { + TAG_NOT_FOUND, + USER_NOT_AUTHORIZED_ERROR, + USER_NOT_FOUND_ERROR, +} from "../../../src/constants"; +import { + AppUserProfile, + OrganizationTagUser, + TagUser, + User, +} from "../../../src/models"; +import type { TestUserTagType } from "../../helpers/tags"; +import { + createRootTagsWithOrg, + createTwoLevelTagsWithOrg, +} from "../../helpers/tags"; +import type { + TestOrganizationType, + TestUserType, +} from "../../helpers/userAndOrg"; +import { createTestUser } from "../../helpers/userAndOrg"; + +let MONGOOSE_INSTANCE: typeof mongoose; + +let adminUser: TestUserType; +let adminUser2: TestUserType; +let testTag2: TestUserTagType; +let testTag3: TestUserTagType; +let testTag: TestUserTagType; +let testSubTag1: TestUserTagType; +let testOrg1: TestOrganizationType; +let testOrg2: TestOrganizationType; +let randomUser1: TestUserType; +let randomUser2: TestUserType; +let randomUser3: TestUserType; + +beforeAll(async () => { + MONGOOSE_INSTANCE = await connect(); + [adminUser, testOrg1, [testTag, testSubTag1]] = + await createTwoLevelTagsWithOrg(); + [adminUser2, testOrg2, [testTag2, testTag3]] = await createRootTagsWithOrg(2); + randomUser1 = await createTestUser(); + randomUser2 = await createTestUser(); + randomUser3 = await createTestUser(); +}); + +afterAll(async () => { + await disconnect(MONGOOSE_INSTANCE); +}); + +describe("resolvers -> Mutation -> removeFromUserTags", () => { + afterEach(() => { + vi.doUnmock("../../../src/constants"); + vi.resetModules(); + vi.resetAllMocks(); + }); + + it(`throws NotFoundError if no user exists with _id === context.userId `, async () => { + await testErrorScenario({ + args: { + input: { + selectedTagIds: [testTag?._id.toString() ?? ""], + currentTagId: testTag?._id.toString() ?? "", + }, + }, + context: { userId: new Types.ObjectId().toString() }, + expectedError: USER_NOT_FOUND_ERROR.MESSAGE, + }); + }); + + it(`throws NotFoundError if no tag exists with _id === args.input.currentTagId `, async () => { + await testErrorScenario({ + args: { + input: { + selectedTagIds: [testTag?._id.toString() ?? ""], + currentTagId: adminUser?._id, + }, + }, + context: { userId: adminUser?._id }, + expectedError: TAG_NOT_FOUND.MESSAGE, + }); + }); + + it(`throws Not Authorized Error if the current user is not a superadmin or admin of the organization `, async () => { + await testErrorScenario({ + args: { + input: { + selectedTagIds: [testTag?._id.toString() ?? ""], + currentTagId: testTag?._id.toString() ?? "", + }, + }, + context: { userId: randomUser1?._id }, + expectedError: USER_NOT_AUTHORIZED_ERROR.MESSAGE, + }); + }); + + it(`throws NotFoundError if one of the selected tags doesn't exist `, async () => { + await testErrorScenario({ + args: { + input: { + selectedTagIds: [ + testTag?._id.toString() ?? "", + new Types.ObjectId().toString(), + ], + currentTagId: testTag?._id.toString() ?? "", + }, + }, + context: { userId: adminUser?._id }, + expectedError: TAG_NOT_FOUND.MESSAGE, + }); + }); + + it("throws error if user does not have appUserProfile", async () => { + const temp = await createTestUser(); + await AppUserProfile.deleteOne({ + userId: temp?._id, + }); + + await testErrorScenario({ + args: { + input: { + selectedTagIds: [testTag?._id.toString() ?? ""], + currentTagId: testTag?._id.toString() ?? "", + }, + }, + context: { userId: temp?._id }, + expectedError: USER_NOT_AUTHORIZED_ERROR.MESSAGE, + }); + }); + + it(`Tag removal should be successful and the tag is returned`, async () => { + // assign testTag2 to random users + await Promise.all([ + User.findOneAndUpdate( + { + _id: randomUser2?._id, + }, + { + joinedOrganizations: testOrg2, + }, + ), + User.findOneAndUpdate( + { + _id: randomUser3?._id, + }, + { + joinedOrganizations: testOrg2, + }, + ), + TagUser.create({ + userId: randomUser2?._id, + tagId: testTag2?._id, + }), + TagUser.create({ + userId: randomUser3?._id, + tagId: testTag2?._id, + }), + ]); + + // now assign them to a new tag with the help of assignToUserTags mutation + const assignToUserTagsArgs: MutationAssignToUserTagsArgs = { + input: { + selectedTagIds: [testTag3?._id.toString() ?? ""], + currentTagId: testTag2?._id.toString() ?? "", + }, + }; + + const assignToUserTagsContext = { + userId: adminUser2?._id, + }; + + const { assignToUserTags: assignToUserTagsResolver } = await import( + "../../../src/resolvers/Mutation/assignToUserTags" + ); + + const assignToUserTagsPayload = await assignToUserTagsResolver?.( + {}, + assignToUserTagsArgs, + assignToUserTagsContext, + ); + + expect(assignToUserTagsPayload?._id.toString()).toEqual( + testTag2?._id.toString(), + ); + + const tagAssignedToRandomUser2 = await TagUser.exists({ + tagId: testTag3, + userId: randomUser2?._id, + }); + + const tagAssignedToRandomUser3 = await TagUser.exists({ + tagId: testTag3, + userId: randomUser3?._id, + }); + + expect(tagAssignedToRandomUser2).toBeTruthy(); + expect(tagAssignedToRandomUser3).toBeTruthy(); + + // now remove them from that tag with the help of removeFromUserTags mutation + const args: MutationRemoveFromUserTagsArgs = { + input: { + selectedTagIds: [testTag3?._id.toString() ?? ""], + currentTagId: testTag2?._id.toString() ?? "", + }, + }; + + const context = { + userId: adminUser2?._id, + }; + + const { removeFromUserTags: removeFromUserTagsResolver } = await import( + "../../../src/resolvers/Mutation/removeFromUserTags" + ); + + const payload = await removeFromUserTagsResolver?.({}, args, context); + + expect(payload?._id.toString()).toEqual(testTag2?._id.toString()); + + const tagExistsForRandomUser2 = await TagUser.exists({ + tagId: testTag3, + userId: randomUser2?._id, + }); + + const tagExistsForRandomUser3 = await TagUser.exists({ + tagId: testTag3, + userId: randomUser3?._id, + }); + + expect(tagExistsForRandomUser2).toBeFalsy(); + expect(tagExistsForRandomUser3).toBeFalsy(); + }); + + it(`Should remove all the decendent tags too and returns the current tag`, async () => { + // create a new tag with the organization + const newTestTag = await OrganizationTagUser.create({ + name: "newTestTag", + organizationId: testOrg1?._id, + }); + + // assign this new test tag to random users + await Promise.all([ + User.findOneAndUpdate( + { + _id: randomUser2?._id, + }, + { + joinedOrganizations: testOrg1, + }, + ), + User.findOneAndUpdate( + { + _id: randomUser3?._id, + }, + { + joinedOrganizations: testOrg1, + }, + ), + TagUser.create({ + userId: randomUser2?._id, + tagId: newTestTag?._id, + }), + TagUser.create({ + userId: randomUser3?._id, + tagId: newTestTag?._id, + }), + ]); + + // now assign them a new sub tag, which will automatically assign them the parent tag also + const assignToUserTagsArgs: MutationAssignToUserTagsArgs = { + input: { + selectedTagIds: [testSubTag1?._id.toString() ?? ""], + currentTagId: newTestTag?._id.toString() ?? "", + }, + }; + const assignToUserTagsContext = { + userId: adminUser?._id, + }; + + const { assignToUserTags: assignToUserTagsResolver } = await import( + "../../../src/resolvers/Mutation/assignToUserTags" + ); + + const assignToUserTagsPayload = await assignToUserTagsResolver?.( + {}, + assignToUserTagsArgs, + assignToUserTagsContext, + ); + + expect(assignToUserTagsPayload?._id.toString()).toEqual( + newTestTag?._id.toString(), + ); + + const subTagAssignedToRandomUser2 = await TagUser.exists({ + tagId: testSubTag1?._id, + userId: randomUser2?._id, + }); + + const subTagAssignedToRandomUser3 = await TagUser.exists({ + tagId: testSubTag1?._id, + userId: randomUser3?._id, + }); + + expect(subTagAssignedToRandomUser2).toBeTruthy(); + expect(subTagAssignedToRandomUser3).toBeTruthy(); + + const ancestorTagAssignedToRandomUser2 = await TagUser.exists({ + tagId: testTag?._id.toString() ?? "", + userId: randomUser2?._id, + }); + + const ancestorTagAssignedToRandomUser3 = await TagUser.exists({ + tagId: testTag?._id.toString() ?? "", + userId: randomUser3?._id, + }); + + expect(ancestorTagAssignedToRandomUser2).toBeTruthy(); + expect(ancestorTagAssignedToRandomUser3).toBeTruthy(); + + // now remove the parent tag, which will also remove the subtags + const args: MutationRemoveFromUserTagsArgs = { + input: { + selectedTagIds: [testTag?._id.toString() ?? ""], + currentTagId: newTestTag?._id.toString() ?? "", + }, + }; + const context = { + userId: adminUser?._id, + }; + + const { removeFromUserTags: removeFromUserTagsResolver } = await import( + "../../../src/resolvers/Mutation/removeFromUserTags" + ); + + const payload = await removeFromUserTagsResolver?.({}, args, context); + + expect(payload?._id.toString()).toEqual(newTestTag?._id.toString()); + + const subTagExistsForRandomUser2 = await TagUser.exists({ + tagId: testSubTag1?._id, + userId: randomUser2?._id, + }); + + const subTagExistsForRandomUser3 = await TagUser.exists({ + tagId: testSubTag1?._id, + userId: randomUser3?._id, + }); + + expect(subTagExistsForRandomUser2).toBeFalsy(); + expect(subTagExistsForRandomUser3).toBeFalsy(); + + const ancestorTagExistsForRandomUser2 = await TagUser.exists({ + tagId: testTag?._id.toString() ?? "", + userId: randomUser2?._id, + }); + + const ancestorTagExistsForRandomUser3 = await TagUser.exists({ + tagId: testTag?._id.toString() ?? "", + userId: randomUser3?._id, + }); + + expect(ancestorTagExistsForRandomUser2).toBeFalsy(); + expect(ancestorTagExistsForRandomUser3).toBeFalsy(); + }); +}); + +const testErrorScenario = async ({ + args, + context, + expectedError, +}: { + args: MutationRemoveFromUserTagsArgs; + context: { userId: string }; + expectedError: string; +}): Promise => { + const { requestContext } = await import("../../../src/libraries"); + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const { removeFromUserTags: removeFromUserTagsResolver } = await import( + "../../../src/resolvers/Mutation/removeFromUserTags" + ); + await removeFromUserTagsResolver?.({}, args, context); + throw new Error("Expected error was not thrown"); + } catch (error: unknown) { + if (error instanceof Error) { + expect(error.message).toEqual(`Translated ${expectedError}`); + } else { + throw new Error("Unexpected error type"); + } + expect(spy).toHaveBeenLastCalledWith(expectedError); + } +};