From d9a6e20b3307db5a976c48f4a3b2f831157ffb92 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Fri, 8 Aug 2025 16:35:58 +0530 Subject: [PATCH 1/6] feat(org-admin): update deactivateUser endpoint to support tenant code --- src/controllers/v1/org-admin.js | 8 --- src/database/queries/users.js | 66 ++++++++++++++++++ src/helpers/userHelper.js | 60 +++++++++++++++++ src/services/org-admin.js | 116 ++++++++++++++++++++++---------- src/validators/v1/org-admin.js | 28 ++++++-- 5 files changed, 232 insertions(+), 46 deletions(-) diff --git a/src/controllers/v1/org-admin.js b/src/controllers/v1/org-admin.js index 28785206..d39cf216 100644 --- a/src/controllers/v1/org-admin.js +++ b/src/controllers/v1/org-admin.js @@ -161,14 +161,6 @@ module.exports = class OrgAdmin { }) } - if (!req.body.id && !req.body.email) { - throw responses.failureResponse({ - message: 'EMAIL_OR_ID_REQUIRED', - statusCode: httpStatusCode.bad_request, - responseCode: 'CLIENT_ERROR', - }) - } - const result = await orgAdminService.deactivateUser(req.body, req.decodedToken) return result diff --git a/src/database/queries/users.js b/src/database/queries/users.js index c98c4d69..51246a98 100644 --- a/src/database/queries/users.js +++ b/src/database/queries/users.js @@ -519,3 +519,69 @@ exports.changeOrganization = async (id, currentOrgId, newOrgId, updateBody = {}) throw error } } + +/** + * Deactivates users within a specific organization and tenant based on the provided filter. + * + * This function: + * - First fetches all users matching the `filter` and ensures they belong to the given `organization_code` and `tenant_code`. + * - Updates the matched users' records with the given `updateData`. + * - Optionally returns the list of matched user records if `returnUpdatedUsers` is set to true. + * + * Note: + * - Users not associated with the given organization/tenant are excluded. + * - This function currently assumes each user belongs to only one organization in this context. + * + * @async + * @param {Object} filter - Sequelize-compatible filter criteria for selecting users. + * @param {string} organization_code - Code of the organization to scope user lookup. + * @param {string} tenant_code - Tenant code for further scoping the user lookup. + * @param {Object} updateData - Fields to update for the matched users (e.g., status, updated_by). + * @param {boolean} [returnUpdatedUsers=false] - Whether to return the list of matched user records. + * + * @returns {Promise<[number, Object[]]>} A tuple: + * - First item: Number of rows affected by the update. + * - Second item: Array of user records if `returnUpdatedUsers` is true, otherwise an empty array. + * + * @throws {Error} Throws if there is any issue during the query or update. + */ + +exports.deactivateUserInOrg = async ( + filter, + organization_code, + tenant_code, + updateData, + returnUpdatedUsers = false +) => { + try { + const users = await database.User.findAll({ + where: filter, + include: [ + { + model: database.UserOrganization, + as: 'user_organizations', + required: true, + where: { + organization_code, + tenant_code, + }, + attributes: [], + }, + ], + attributes: ['id'], + }) + + const userIds = users.map((u) => u.id) + + if (userIds.length === 0) return [0, []] + + const [rowsAffected] = await database.User.update(updateData, { + where: { id: { [Op.in]: userIds } }, + }) + + return [rowsAffected, returnUpdatedUsers ? users : []] + } catch (error) { + console.error('Error in deactivateUserInOrg:', error) + throw error + } +} diff --git a/src/helpers/userHelper.js b/src/helpers/userHelper.js index 58b93cd5..d3c16098 100644 --- a/src/helpers/userHelper.js +++ b/src/helpers/userHelper.js @@ -7,6 +7,7 @@ const userSessionsService = require('@services/user-sessions') const Sequelize = require('@database/models/index').sequelize const REDIS_USER_PREFIX = common.redisUserPrefix const DELETED_STATUS = common.DELETED_STATUS +const userSessionsQueries = require('@database/queries/user-sessions') function generateUpdateParams(userId) { return { @@ -94,6 +95,65 @@ const userHelper = { transactionOptions ) }, + + /** + * Removes all active sessions for the specified user(s) within a tenant. + * + * This function performs the following: + * - Accepts one or more user IDs and ensures they belong to the given tenant. + * - Retrieves all active sessions (where `ended_at` is null). + * - Deletes associated session keys from Redis. + * - Marks the sessions as ended in the database by setting `ended_at` to the current timestamp. + * + * @async + * @param {number|number[]} userIds - A single user ID or an array of user IDs whose sessions should be removed. + * @param {string} tenantCode - The tenant code used to scope session lookup. + * + * @returns {Promise<{ success: boolean, removedCount: number }>} Object indicating success status and number of sessions removed. + * + * @throws {Error} If any step in the removal process fails (Redis or DB update). + */ + + async removeAllUserSessions(userIds, tenantCode) { + try { + const userIdArray = Array.isArray(userIds) ? userIds : [userIds] + + if (userIdArray.length === 0) { + return { success: true, removedCount: 0 } + } + + // Find all active sessions for the user(s) + const sessions = await userSessionsQueries.findAll( + { + user_id: userIdArray, + tenant_code: tenantCode, + ended_at: null, + }, + { attributes: ['id'] } + ) + + const sessionIds = sessions.map((s) => s.id) + + if (sessionIds.length === 0) { + return { success: true, removedCount: 0 } + } + + // Delete from Redis + await Promise.all(sessionIds.map((id) => utils.redisDel(id.toString()))) + + // Mark sessions as ended in DB + const currentTime = Math.floor(Date.now() / 1000) + const updateResult = await userSessionsQueries.update({ id: sessionIds }, { ended_at: currentTime }) + + if (updateResult instanceof Error) { + throw updateResult + } + + return { success: true, removedCount: sessionIds.length } + } catch (error) { + throw new Error(`Failed to remove sessions: ${error.message}`) + } + }, } module.exports = userHelper diff --git a/src/services/org-admin.js b/src/services/org-admin.js index 2ead572d..0dc060fd 100644 --- a/src/services/org-admin.js +++ b/src/services/org-admin.js @@ -22,11 +22,12 @@ const userOrganizationRoleQueries = require('@database/queries/userOrganizationR const { eventBroadcaster } = require('@helpers/eventBroadcaster') const { Queue } = require('bullmq') const { Op } = require('sequelize') -const UserCredentialQueries = require('@database/queries/userCredential') + const tenantDomainQueries = require('@database/queries/tenantDomain') const emailEncryption = require('@utils/emailEncryption') const responses = require('@helpers/responses') const notificationUtils = require('@utils/notification') +const userHelper = require('@helpers/userHelper') module.exports = class OrgAdminHelper { /** @@ -400,46 +401,85 @@ module.exports = class OrgAdminHelper { } /** - * Deactivate User - * @method - * @name deactivateUser - * @param {Number} id - user id - * @param {Object} loggedInUserId - logged in user id - * @returns {JSON} - Deactivated user data + * Deactivates users by their IDs or email addresses within a specific organization and tenant. + * + * This function performs the following: + * - Deactivates users by matching user IDs (if provided) under the same tenant and organization. + * - Deactivates users by matching emails (if provided) under the same tenant and organization. + * - Broadcasts an event to handle cleanup of upcoming sessions for deactivated users. + * - Removes all active sessions for the affected users. + * + * Note: + * - This function currently does **not** support users associated with multiple organizations. + * It only considers users under the organization specified in `tokenInformation.organization_code`. + * + * @async + * @param {Object} bodyData - Request payload containing user identifiers. + * @param {number[]} [bodyData.ids] - Optional array of user IDs to deactivate. + * @param {string[]} [bodyData.emails] - Optional array of user emails to deactivate. + * + * @param {Object} tokenInformation - Authenticated user's context information. + * @param {string} tokenInformation.organization_code - The organization code for scoping the operation. + * @param {string} tokenInformation.tenant_code - The tenant code to match users within the same tenant. + * @param {number} tokenInformation.id - The ID of the user initiating the deactivation (used as `updated_by`). + * + * @returns {Promise} Response object with success or failure details. + * - If successful, includes the user IDs that were deactivated. + * - If no users were updated, returns a failure response with appropriate status. + * + * @throws {Error} Throws error on unexpected failure during the deactivation process. */ + static async deactivateUser(bodyData, tokenInformation) { try { - let filterQuery = { - organization_id: tokenInformation.organization_id, - } + const { ids = [], emails = [] } = bodyData - for (let item in bodyData) { - filterQuery[item] = { - [Op.in]: bodyData[item], - } + let totalRowsAffected = 0 + const updatedByIds = [] + const updatedByEmails = [] + + // Deactivate by IDs + if (ids.length) { + const [rowsAffected, updatedUsers] = await userQueries.deactivateUserInOrg( + { + id: { [Op.in]: ids }, + tenant_code: tokenInformation.tenant_code, + }, + tokenInformation.organization_code, + tokenInformation.tenant_code, + { + status: common.INACTIVE_STATUS, + updated_by: tokenInformation.id, + }, + true // pass flag to return matched users (optional) + ) + console.log(rowsAffected, updatedUsers) + totalRowsAffected += rowsAffected + updatedByIds.push(...updatedUsers.map((user) => user.id)) } - let userIds = [] - if (bodyData.email) { - const encryptedEmailIds = bodyData.email.map((email) => emailEncryption.encrypt(email.toLowerCase())) - const userCredentials = await UserCredentialQueries.findAll( - { email: { [Op.in]: encryptedEmailIds } }, + // Deactivate by Emails + if (emails.length) { + const [rowsAffected, updatedUsers] = await userQueries.deactivateUserInOrg( { - attributes: ['user_id'], - } + email: { [Op.in]: emails.map((email) => emailEncryption.encrypt(email.toLowerCase())) }, + tenant_code: tokenInformation.tenant_code, + }, + tokenInformation.organization_code, + tokenInformation.tenant_code, + { + status: common.INACTIVE_STATUS, + updated_by: tokenInformation.id, + }, + true // return updated users ) - userIds = _.map(userCredentials, 'user_id') - delete filterQuery.email - filterQuery.id = userIds - } else { - userIds = bodyData.id + + totalRowsAffected += rowsAffected + updatedByEmails.push(...updatedUsers.map((user) => user.id)) } - let [rowsAffected] = await userQueries.updateUser(filterQuery, { - status: common.INACTIVE_STATUS, - updated_by: tokenInformation.id, - }) - if (rowsAffected == 0) { + // If nothing was deactivated + if (totalRowsAffected === 0) { return responses.failureResponse({ message: 'STATUS_UPDATE_FAILED', statusCode: httpStatusCode.bad_request, @@ -447,19 +487,27 @@ module.exports = class OrgAdminHelper { }) } - //check and deactivate upcoming sessions + // Broadcast event + const allUserIds = [...new Set([...updatedByIds, ...updatedByEmails])] + + userHelper.removeAllUserSessions(allUserIds, tokenInformation.tenant_code) + eventBroadcaster('deactivateUpcomingSession', { requestBody: { - user_ids: userIds, + user_ids: allUserIds, }, }) return responses.successResponse({ statusCode: httpStatusCode.ok, message: 'USER_DEACTIVATED', + result: { + updated_by_ids: updatedByIds, + updated_by_emails: updatedByEmails, + }, }) } catch (error) { - console.log(error) + console.error('Error in deactivateUser:', error) throw error } } diff --git a/src/validators/v1/org-admin.js b/src/validators/v1/org-admin.js index 52d44e43..a6deeca5 100644 --- a/src/validators/v1/org-admin.js +++ b/src/validators/v1/org-admin.js @@ -28,10 +28,30 @@ module.exports = { req.checkBody('status').notEmpty().withMessage('status field is empty') }, deactivateUser: (req) => { - const field = req.body.email ? 'email' : req.body.id ? 'id' : null - if (field) { - req.checkBody(field).isArray().notEmpty().withMessage(` ${field} must be an array and should not be empty.`) - } + req.checkBody('emails').optional().isArray().withMessage('The "emails" field must be an array, if provided.') + + req.checkBody('emails.*') + .optional() + .isEmail() + .withMessage('Each item in the "emails" array must be a valid email address.') + + req.checkBody('ids').optional().isArray().withMessage('The "ids" field must be an array, if provided.') + + req.checkBody('ids.*') + .optional() + .isNumeric() + .withMessage('Each item in the "ids" array must be a numeric value.') + + req.checkBody(['emails', 'ids']).custom(() => { + const ids = req.body.ids + const emails = req.body.emails + + if (!emails && !ids) { + throw new Error('At least one of "emails" or "ids" must be provided.') + } + + return true + }) }, inheritEntityType: (req) => { // Validate incoming request body From fc8f3d06817e7af9de646b5c6188565585535adf Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Fri, 8 Aug 2025 20:55:03 +0530 Subject: [PATCH 2/6] improved validation --- src/validators/v1/org-admin.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/validators/v1/org-admin.js b/src/validators/v1/org-admin.js index a6deeca5..fe76893d 100644 --- a/src/validators/v1/org-admin.js +++ b/src/validators/v1/org-admin.js @@ -46,8 +46,8 @@ module.exports = { const ids = req.body.ids const emails = req.body.emails - if (!emails && !ids) { - throw new Error('At least one of "emails" or "ids" must be provided.') + if ((!emails || emails.length === 0) && (!ids || ids.length === 0)) { + throw new Error('Provide at least one non-empty "emails" or "ids" array.') } return true From a9538e3c18be29e6daf1b4ca20508d829f74ee4e Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Fri, 8 Aug 2025 20:55:54 +0530 Subject: [PATCH 3/6] add: await to removeAllUserSessions --- src/services/org-admin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/org-admin.js b/src/services/org-admin.js index 0dc060fd..3ae9964d 100644 --- a/src/services/org-admin.js +++ b/src/services/org-admin.js @@ -490,7 +490,7 @@ module.exports = class OrgAdminHelper { // Broadcast event const allUserIds = [...new Set([...updatedByIds, ...updatedByEmails])] - userHelper.removeAllUserSessions(allUserIds, tokenInformation.tenant_code) + await userHelper.removeAllUserSessions(allUserIds, tokenInformation.tenant_code) eventBroadcaster('deactivateUpcomingSession', { requestBody: { From 908ef73b42a568dc9c053563df512004702f5534 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Fri, 8 Aug 2025 20:59:01 +0530 Subject: [PATCH 4/6] add: tenantCode to user query --- src/database/queries/users.js | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/database/queries/users.js b/src/database/queries/users.js index 51246a98..fef16002 100644 --- a/src/database/queries/users.js +++ b/src/database/queries/users.js @@ -546,13 +546,7 @@ exports.changeOrganization = async (id, currentOrgId, newOrgId, updateBody = {}) * @throws {Error} Throws if there is any issue during the query or update. */ -exports.deactivateUserInOrg = async ( - filter, - organization_code, - tenant_code, - updateData, - returnUpdatedUsers = false -) => { +exports.deactivateUserInOrg = async (filter, organizationCode, tenantCode, updateData, returnUpdatedUsers = false) => { try { const users = await database.User.findAll({ where: filter, @@ -562,8 +556,8 @@ exports.deactivateUserInOrg = async ( as: 'user_organizations', required: true, where: { - organization_code, - tenant_code, + organization_code: organizationCode, + tenant_code: tenantCode, }, attributes: [], }, @@ -576,7 +570,7 @@ exports.deactivateUserInOrg = async ( if (userIds.length === 0) return [0, []] const [rowsAffected] = await database.User.update(updateData, { - where: { id: { [Op.in]: userIds } }, + where: { id: { [Op.in]: userIds, tenant_code: tenantCode } }, }) return [rowsAffected, returnUpdatedUsers ? users : []] From 8e2c8f05dc8ac801ae5791579908f718f7e5c98d Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Fri, 8 Aug 2025 21:02:22 +0530 Subject: [PATCH 5/6] fix: deactivateUserInOrg --- src/database/queries/users.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/queries/users.js b/src/database/queries/users.js index fef16002..75a004fd 100644 --- a/src/database/queries/users.js +++ b/src/database/queries/users.js @@ -570,7 +570,7 @@ exports.deactivateUserInOrg = async (filter, organizationCode, tenantCode, updat if (userIds.length === 0) return [0, []] const [rowsAffected] = await database.User.update(updateData, { - where: { id: { [Op.in]: userIds, tenant_code: tenantCode } }, + where: { id: { [Op.in]: userIds }, tenant_code: tenantCode }, }) return [rowsAffected, returnUpdatedUsers ? users : []] From bce9b4f733856ff2a2e62e423d85b422ab2e32ec Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 12 Aug 2025 13:21:45 +0530 Subject: [PATCH 6/6] fix: password policy message --- src/envVariables.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/envVariables.js b/src/envVariables.js index 6d38bf05..dbb6ee3e 100644 --- a/src/envVariables.js +++ b/src/envVariables.js @@ -209,7 +209,7 @@ let enviromentVariables = { message: 'Required password policy message', optional: true, default: - 'Password must have at least one uppercase letter, one number, one special character, and be at least 10 characters long', + 'Password must have at least two uppercase letters, two numbers, three special characters, and be at least 11 characters long.', }, DOWNLOAD_URL_EXPIRATION_DURATION: { message: 'Required downloadable url expiration time',