diff --git a/src/database/queries/mentorExtension.js b/src/database/queries/mentorExtension.js index 522a59ecb..2d3aaefa1 100644 --- a/src/database/queries/mentorExtension.js +++ b/src/database/queries/mentorExtension.js @@ -54,9 +54,13 @@ module.exports = class MentorExtensionQueries { try { data = { ...data, is_mentor: true } + // Remove partition key columns - Citus doesn't allow updating these if (data.user_id) { delete data['user_id'] } + if (data.tenant_code) { + delete data['tenant_code'] + } let whereClause if (_.isEmpty(customFilter)) { @@ -159,10 +163,17 @@ module.exports = class MentorExtensionQueries { for (const [key, attribute] of Object.entries(modelAttributes)) { // Skip primary key or explicitly excluded fields + // FIX: Added organization_code and tenant_code to exclusion list + // ROOT CAUSE: These fields have NOT NULL constraints in the database. + // When removeMentorDetails() tried to set them to null, Sequelize threw: + // "notNull Violation: UserExtension.organization_code cannot be null" + // SOLUTION: Exclude these fields from nullification to maintain data integrity if ( attribute.primaryKey || key === 'user_id' || key === 'organization_id' || // required field + key === 'organization_code' || // FIX: required NOT NULL field - cannot be nullified + key === 'tenant_code' || // FIX: required NOT NULL field - cannot be nullified key === 'created_at' || key === 'updated_at' || key === 'is_mentor' // has default value diff --git a/src/database/queries/roleExtentions.js b/src/database/queries/roleExtentions.js index dfb82aac4..37ac9dc2e 100644 --- a/src/database/queries/roleExtentions.js +++ b/src/database/queries/roleExtentions.js @@ -76,6 +76,10 @@ module.exports = class RoleExtensionService { static async updateRoleExtension(title, updateData, tenantCode) { try { + // Remove partition key columns - Citus doesn't allow updating these + if (updateData.tenant_code) { + delete updateData['tenant_code'] + } const filter = { title: title, tenant_code: tenantCode } const [rowsUpdated, updatedExtension] = await RoleExtension.update(updateData, { diff --git a/src/database/queries/sessions.js b/src/database/queries/sessions.js index a648019e1..4d45ffed0 100644 --- a/src/database/queries/sessions.js +++ b/src/database/queries/sessions.js @@ -1099,15 +1099,23 @@ exports.getSessionsAssignedToMentor = async (mentorUserId, tenantCode) => { } } -exports.getSessionsAssignedToMentor = async (mentorUserId) => { +// FIX: Added tenantCode parameter and included it in JOIN and WHERE clauses +// ROOT CAUSE: Citus distributed database requires JOINs to include the distribution column (tenant_code). +// Original query failed with: "complex joins are only supported when all distributed tables are +// co-located and joined on their distribution columns" +// SOLUTION: Added tenant_code to both the JOIN condition (line 1108) and WHERE clause (line 1112) +// to ensure Citus can properly route the query across distributed shards. +exports.getSessionsAssignedToMentor = async (mentorUserId, tenantCode) => { try { const query = ` SELECT s.*, sa.mentee_id FROM ${Session.tableName} s LEFT JOIN session_attendees sa ON s.id = sa.session_id - WHERE s.mentor_id = :mentorUserId + AND s.tenant_code = sa.tenant_code + WHERE s.mentor_id = :mentorUserId AND s.start_date > :currentTime AND s.deleted_at IS NULL + AND s.tenant_code = :tenantCode ` const sessionsToDelete = await Sequelize.query(query, { @@ -1115,6 +1123,7 @@ exports.getSessionsAssignedToMentor = async (mentorUserId) => { replacements: { mentorUserId, currentTime: Math.floor(Date.now() / 1000), + tenantCode, }, }) diff --git a/src/database/queries/userExtension.js b/src/database/queries/userExtension.js index 0529f61a5..4f5589f18 100644 --- a/src/database/queries/userExtension.js +++ b/src/database/queries/userExtension.js @@ -35,9 +35,13 @@ module.exports = class MenteeExtensionQueries { static async updateMenteeExtension(userId, data, options = {}, customFilter = {}, tenantCode) { try { + // Remove partition key columns - Citus doesn't allow updating these if (data.user_id) { delete data['user_id'] } + if (data.tenant_code) { + delete data['tenant_code'] + } let whereClause if (_.isEmpty(customFilter)) { whereClause = { user_id: userId, tenant_code: tenantCode } diff --git a/src/helpers/organizationCache.js b/src/helpers/organizationCache.js new file mode 100644 index 000000000..23093a9b8 --- /dev/null +++ b/src/helpers/organizationCache.js @@ -0,0 +1,41 @@ +'use strict' + +/** + * Organization Cache Helper + * + * FIX: Created this wrapper to break circular dependency + * + * ROOT CAUSE: Circular dependency chain caused "getDefaults is not a function" error: + * cacheHelper.js → getDefaultOrgId.js → user.js → cacheHelper.js + * + * When Node.js loads these modules: + * 1. cacheHelper.js starts loading, imports getDefaultOrgId.js + * 2. getDefaultOrgId.js imports user.js (for userRequests.fetchOrgDetails) + * 3. user.js imports cacheHelper.js + * 4. cacheHelper.js is not fully loaded yet, returns partial/empty exports + * 5. getDefaults function is undefined at this point + * + * SOLUTION: user.js now imports this wrapper instead of cacheHelper directly. + * This breaks the cycle because: + * cacheHelper.js → getDefaultOrgId.js → user.js → organizationCache.js → cacheHelper.js + * + * Even though there's still a path to cacheHelper, the actual cacheHelper functions + * are only called at RUNTIME (inside set/get functions), not at MODULE LOAD TIME. + * By the time these functions are called, all modules are fully loaded. + */ + +const cacheHelper = require('@generics/cacheHelper') + +/** + * Set organization details in cache + */ +exports.set = async (tenantCode, orgCode, orgId, data) => { + return await cacheHelper.organizations.set(tenantCode, orgCode, orgId, data) +} + +/** + * Get organization details from cache + */ +exports.get = async (tenantCode, orgCode, orgId) => { + return await cacheHelper.organizations.get(tenantCode, orgCode, orgId) +} diff --git a/src/requests/user.js b/src/requests/user.js index 0a3bff754..b7e003b80 100644 --- a/src/requests/user.js +++ b/src/requests/user.js @@ -14,12 +14,14 @@ const httpStatusCode = require('@generics/http-status') const responses = require('@helpers/responses') const common = require('@constants/common') const { Op } = require('sequelize') -const cacheHelper = require('@generics/cacheHelper') const usersHelper = require('@helpers/users') +// FIX: Use organizationCache wrapper instead of cacheHelper directly +// This breaks the circular dependency: cacheHelper → getDefaultOrgId → user.js → cacheHelper +// See helpers/organizationCache.js for detailed explanation +const organizationCache = require('@helpers/organizationCache') const menteeQueries = require('@database/queries/userExtension') const organisationExtensionQueries = require('@database/queries/organisationExtension') -// Removed cacheHelper to break circular dependency with getDefaultOrgId const emailEncryption = require('@utils/emailEncryption') const _ = require('lodash') @@ -99,7 +101,7 @@ const getOrgDetails = async function ({ organizationId, tenantCode }) { // If we got the data, populate the cache for future use if (organizationDetails?.organization_code) { try { - await cacheHelper.organizations.set( + await organizationCache.set( tenantCode, organizationDetails.organization_code, organizationId, @@ -885,11 +887,7 @@ const getUserDetailedListUsingCache = async function (userIds, tenantCode, delet const cacheResults = await Promise.all( organizations.map(async (org) => ({ org, - orgCachedData: await cacheHelper.organizations.get( - tenantCode, - org.organization_code, - org.organization_id - ), + orgCachedData: await organizationCache.get(tenantCode, org.organization_code, org.organization_id), })) ) diff --git a/src/services/admin.js b/src/services/admin.js index 54957411f..7052491ad 100644 --- a/src/services/admin.js +++ b/src/services/admin.js @@ -170,7 +170,7 @@ module.exports = class AdminService { let result = {} // Step 1: Fetch user details - let getUserDetails = [] + let userInfo = null let userTenantCode = tenantCode // Optimization: If admin, query directly without tenant restriction (1 query) @@ -182,18 +182,17 @@ module.exports = class AdminService { getUserDetails = userDetail ? [userDetail] : [] } else { // Regular user deleting themselves - use tenant code from token (optimized path) - getUserDetails = await menteeQueries.getUsersByUserIds([userId], {}, tenantCode) + const getUserDetails = await menteeQueries.getUsersByUserIds([userId], {}, tenantCode) + userInfo = getUserDetails?.[0] } - if (!getUserDetails || getUserDetails.length === 0) { + if (!userInfo) { return responses.failureResponse({ statusCode: httpStatusCode.bad_request, message: 'USER_NOT_FOUND', result, }) } - - const userInfo = getUserDetails[0] const isMentor = userInfo.is_mentor === true // Step 2: Check if user is a session manager @@ -763,7 +762,17 @@ module.exports = class AdminService { const sentRequestsData = sentRequests.rows || [] // Get requests where user is requestee (received requests) - const sessionRequestMapping = await sessionRequestMappingQueries.getSessionsMapping(userId, tenantCode) + // FIX: Added missing 'status' parameter to getSessionsMapping call + // ROOT CAUSE: Function signature is getSessionsMapping(userId, status, tenantCode) but was called + // with only (userId, tenantCode). This caused tenantCode to be passed as 'status' parameter, + // and the actual tenantCode parameter received 'undefined'. + // Error: "WHERE parameter 'tenant_code' has invalid 'undefined' value" + // SOLUTION: Pass all three parameters in correct order: userId, status, tenantCode + const sessionRequestMapping = await sessionRequestMappingQueries.getSessionsMapping( + userId, + common.CONNECTIONS_STATUS.REQUESTED, + tenantCode + ) const sessionRequestIds = Array.isArray(sessionRequestMapping) ? sessionRequestMapping.map((s) => s.request_session_id) : [] @@ -1133,7 +1142,7 @@ module.exports = class AdminService { orgCode: orgCodes, templateData: { menteeName }, subjectData: { menteeName }, - tenantCodes, + tenantCode: tenantCodes, }) } @@ -1190,7 +1199,7 @@ module.exports = class AdminService { sessionTime: sessionDateTime.format('hh:mm A'), }, subjectData: { sessionName: sessionDetails.title }, - tenantCodes, + tenantCode: tenantCodes, }) } catch (error) { console.error('Error notifying mentor about private session cancellation:', error) @@ -1408,6 +1417,12 @@ module.exports = class AdminService { } } + // FIX: Changed 'tenantCodes,' to 'tenantCode: tenantCodes,' + // ROOT CAUSE: Using shorthand 'tenantCodes,' creates property {tenantCodes: tenantCodes} + // but sendGenericNotification() expects {tenantCode: ...} (singular, not plural). + // This caused template lookup to fail with undefined tenant, leading to: + // "Cannot read properties of undefined (reading 'replace')" when composing email body. + // SOLUTION: Explicitly map the parameter name: tenantCode: tenantCodes static async notifyMenteesAboutMentorDeletion(mentees, mentorName, orgCodes, tenantCodes) { return await NotificationHelper.sendGenericNotification({ recipients: mentees, @@ -1415,21 +1430,24 @@ module.exports = class AdminService { orgCode: orgCodes, templateData: { mentorName }, subjectData: { mentorName }, - tenantCodes, + tenantCode: tenantCodes, }) } + // FIX: Removed JOIN with non-existent request_session_mapping table + // ROOT CAUSE: Original query tried to JOIN with 'request_session_mapping' table that doesn't exist: + // "relation 'request_session_mapping' does not exist" + // SOLUTION: Query session_request table directly since requestee_id is already a column in it. + // The RequestSession model (line 15-18) already has requestee_id field, no mapping table needed. static async getPendingSessionRequestsForMentor(mentorUserId, tenantCode) { try { const query = ` - SELECT rs.*, rm.requestee_id - FROM ${RequestSession.tableName} rs - INNER JOIN request_session_mapping rm ON rs.id = rm.request_session_id - WHERE rm.requestee_id = :mentorUserId - AND rs.status = :requestedStatus - AND rs.deleted_at IS NULL - AND rs.tenant_code = :tenantCode - AND rm.tenant_code = :tenantCode + SELECT * + FROM ${RequestSession.tableName} + WHERE requestee_id = :mentorUserId + AND status = :requestedStatus + AND deleted_at IS NULL + AND tenant_code = :tenantCode ` const pendingRequests = await sequelize.query(query, { @@ -1476,7 +1494,7 @@ module.exports = class AdminService { orgCode: orgCodes, templateData: { sessionName: request.title }, subjectData: { sessionName: request.title }, - tenantCodes, + tenantCode: tenantCodes, }) } } @@ -1532,7 +1550,7 @@ module.exports = class AdminService { orgCode: orgCodes, templateData: { mentorName, sessionList }, subjectData: { mentorName }, - tenantCodes, + tenantCode: tenantCodes, }) } }) @@ -1589,7 +1607,7 @@ module.exports = class AdminService { orgCode: orgCodes, templateData: { menteeName: menteeName, sessionList: sessionList }, subjectData: { menteeName: menteeName }, - tenantCodes, + tenantCode: tenantCodes, }) } }) @@ -1643,7 +1661,7 @@ module.exports = class AdminService { orgCode: orgCodes, templateData: { sessionName: session.title }, subjectData: { sessionName: session.title }, - tenantCodes, + tenantCode: tenantCodes, }) } }