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..75a004fd 100644 --- a/src/database/queries/users.js +++ b/src/database/queries/users.js @@ -519,3 +519,63 @@ 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, organizationCode, tenantCode, updateData, returnUpdatedUsers = false) => { + try { + const users = await database.User.findAll({ + where: filter, + include: [ + { + model: database.UserOrganization, + as: 'user_organizations', + required: true, + where: { + organization_code: organizationCode, + tenant_code: tenantCode, + }, + 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 }, tenant_code: tenantCode }, + }) + + return [rowsAffected, returnUpdatedUsers ? users : []] + } catch (error) { + console.error('Error in deactivateUserInOrg:', error) + throw error + } +} 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', 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..3ae9964d 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])] + + await 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..fe76893d 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 || emails.length === 0) && (!ids || ids.length === 0)) { + throw new Error('Provide at least one non-empty "emails" or "ids" array.') + } + + return true + }) }, inheritEntityType: (req) => { // Validate incoming request body