Skip to content

Commit

Permalink
feat: Add support for bulk tag operations (GSoC) (#2616)
Browse files Browse the repository at this point in the history
* add addPeopleToTag functionality

* lint fix

* improve code

* minor correction

* add tag actions

* fix

* types change

* fix linting error

* fix linting

* corrections

* more corrections

* minor change

* variable name fix
  • Loading branch information
meetulr authored Oct 28, 2024
1 parent 5392ea0 commit 2c372d9
Show file tree
Hide file tree
Showing 9 changed files with 1,109 additions and 1 deletion.
7 changes: 7 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -1609,6 +1611,11 @@ type Subscription {
onPluginUpdate: Plugin
}

input TagActionsInput {
currentTagId: ID!
selectedTagIds: [ID!]!
}

scalar Time

input ToggleUserTagAssignInput {
Expand Down
168 changes: 168 additions & 0 deletions src/resolvers/Mutation/assignToUserTags.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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;
};
6 changes: 5 additions & 1 deletion src/resolvers/Mutation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -127,10 +129,11 @@ export const Mutation: MutationResolvers = {
addUserImage,
addUserToUserFamily,
addPeopleToUserTag,
assignUserTag,
assignToUserTags,
removeUserFamily,
removeUserFromUserFamily,
createUserFamily,
assignUserTag,
blockPluginCreationBySuperadmin,
blockUser,
cancelMembershipRequest,
Expand Down Expand Up @@ -197,6 +200,7 @@ export const Mutation: MutationResolvers = {
removeUserCustomData,
removeUserImage,
removeUserTag,
removeFromUserTags,
resetCommunity,
revokeRefreshTokenForUser,
saveFcmToken,
Expand Down
165 changes: 165 additions & 0 deletions src/resolvers/Mutation/removeFromUserTags.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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;
};
Loading

0 comments on commit 2c372d9

Please sign in to comment.