Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions src/controllers/v1/org-admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions src/database/queries/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
2 changes: 1 addition & 1 deletion src/envVariables.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
60 changes: 60 additions & 0 deletions src/helpers/userHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
116 changes: 82 additions & 34 deletions src/services/org-admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -400,66 +401,113 @@ 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<Object>} 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,
responseCode: 'CLIENT_ERROR',
})
}

//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
}
}
Expand Down
28 changes: 24 additions & 4 deletions src/validators/v1/org-admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down