diff --git a/src/configs/postgres.js b/src/configs/postgres.js index d64042f7..afc000c6 100644 --- a/src/configs/postgres.js +++ b/src/configs/postgres.js @@ -26,6 +26,19 @@ module.exports = { updatedAt: 'updated_at', deletedAt: 'deleted_at', }, + pool: { + max: parseInt(process.env.DB_POOL_MAX), + min: parseInt(process.env.DB_POOL_MIN), + acquire: parseInt(process.env.DB_POOL_ACQUIRE_MS), + idle: parseInt(process.env.DB_POOL_IDLE_MS), + evict: parseInt(process.env.DB_POOL_EVICT_MS), + }, + dialectOptions: { + application_name: process.env.APP_NAME, + keepAlive: true, + statement_timeout: parseInt(process.env.PG_STATEMENT_TIMEOUT_MS), + idle_in_transaction_session_timeout: parseInt(process.env.PG_IDLE_TX_TIMEOUT_MS), + }, //logging: false, defaultOrgId: parseInt(process.env.DEFAULT_ORG_ID), }, @@ -37,6 +50,19 @@ module.exports = { production: { url: process.env.DATABASE_URL, dialect: 'postgres', + pool: { + max: parseInt(process.env.DB_POOL_MAX), + min: parseInt(process.env.DB_POOL_MIN), + acquire: parseInt(process.env.DB_POOL_ACQUIRE_MS), + idle: parseInt(process.env.DB_POOL_IDLE_MS), + evict: parseInt(process.env.DB_POOL_EVICT_MS), + }, + dialectOptions: { + application_name: process.env.APP_NAME, + keepAlive: true, + statement_timeout: parseInt(process.env.PG_STATEMENT_TIMEOUT_MS), + idle_in_transaction_session_timeout: parseInt(process.env.PG_IDLE_TX_TIMEOUT_MS), + }, defaultOrgId: parseInt(process.env.DEFAULT_ORG_ID), }, } diff --git a/src/constants/common.js b/src/constants/common.js index 68580fbe..03f4eccb 100644 --- a/src/constants/common.js +++ b/src/constants/common.js @@ -41,7 +41,9 @@ module.exports = { refreshTokenLimit: 3, otpExpirationTime: process.env.OTP_EXP_TIME, // In Seconds, ADMIN_ROLE: 'admin', + TENANT_ADMIN_ROLE: 'tenant_admin', ORG_ADMIN_ROLE: 'org_admin', + TENANT_ADMIN_ROLE: 'tenant_admin', USER_ROLE: 'user', SESSION_MANAGER_ROLE: 'session_manager', PUBLIC_ROLE: 'public', diff --git a/src/controllers/v1/admin.js b/src/controllers/v1/admin.js index 1067e2b0..c9e7b6e8 100644 --- a/src/controllers/v1/admin.js +++ b/src/controllers/v1/admin.js @@ -38,6 +38,49 @@ module.exports = class Admin { } } + /** + * Assigns a role to a user. + * + * Extracts `organization_code` and `tenant_code` from `req.decodedToken` + * instead of the request body and delegates the actual role assignment + * to the service layer. Performs only an admin-access check. + * + * @async + * @function assignRole + * @param {import('express').Request} req - Express request object + * @param {Object} req.decodedToken - Decoded JWT payload + * @param {string} req.decodedToken.organization_code - Organization code from token + * @param {string} req.decodedToken.tenant_code - Tenant code from token + * @param {Object} req.body - Request body containing assignment data + * @param {number|string} req.body.user_id - Target user ID + * @param {number|string} req.body.role_id - Role ID to assign + * @param {number|string} [req.body.organization_id] - Optional organization ID + * @returns {Promise} Service response object + */ + + async assignRole(req) { + try { + if (!utilsHelper.validateRoleAccess(req.decodedToken.roles, common.ADMIN_ROLE)) { + throw responses.failureResponse({ + message: 'USER_IS_NOT_A_ADMIN', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + + const params = { + organization_code: req.decodedToken.organization_code, + tenant_code: req.decodedToken.tenant_code, + } + + // Pass token-derived params separately per new service pattern + const user = await adminService.assignRole(params, req.body) + return user + } catch (error) { + return error + } + } + /** * create admin users * @method diff --git a/src/controllers/v1/notification.js b/src/controllers/v1/notification.js index 121036ce..ffdfbfb4 100644 --- a/src/controllers/v1/notification.js +++ b/src/controllers/v1/notification.js @@ -29,7 +29,13 @@ module.exports = class NotificationTemplate { async template(req) { try { - if (!utilsHelper.validateRoleAccess(req.decodedToken.roles, [common.ADMIN_ROLE, common.ORG_ADMIN_ROLE])) { + if ( + !utilsHelper.validateRoleAccess(req.decodedToken.roles, [ + common.ADMIN_ROLE, + common.ORG_ADMIN_ROLE, + common.TENANT_ADMIN_ROLE, + ]) + ) { throw responses.failureResponse({ message: 'USER_IS_NOT_A_ADMIN', statusCode: httpStatusCode.bad_request, diff --git a/src/controllers/v1/org-admin.js b/src/controllers/v1/org-admin.js index d39cf216..926661b4 100644 --- a/src/controllers/v1/org-admin.js +++ b/src/controllers/v1/org-admin.js @@ -21,7 +21,12 @@ module.exports = class OrgAdmin { */ async bulkUserCreate(req) { try { - if (!utilsHelper.validateRoleAccess(req.decodedToken.roles, common.ORG_ADMIN_ROLE)) { + if ( + !utilsHelper.validateRoleAccess(req.decodedToken.roles, [ + common.ORG_ADMIN_ROLE, + common.TENANT_ADMIN_ROLE, + ]) + ) { throw responses.failureResponse({ message: 'USER_IS_NOT_A_ADMIN', statusCode: httpStatusCode.bad_request, @@ -48,7 +53,12 @@ module.exports = class OrgAdmin { */ async getBulkInvitesFilesList(req) { try { - if (!utilsHelper.validateRoleAccess(req.decodedToken.roles, common.ORG_ADMIN_ROLE)) { + if ( + !utilsHelper.validateRoleAccess(req.decodedToken.roles, [ + common.ORG_ADMIN_ROLE, + common.TENANT_ADMIN_ROLE, + ]) + ) { throw responses.failureResponse({ message: 'USER_IS_NOT_A_ADMIN', statusCode: httpStatusCode.bad_request, @@ -72,7 +82,12 @@ module.exports = class OrgAdmin { */ async getRequestDetails(req) { try { - if (!utilsHelper.validateRoleAccess(req.decodedToken.roles, common.ORG_ADMIN_ROLE)) { + if ( + !utilsHelper.validateRoleAccess(req.decodedToken.roles, [ + common.ORG_ADMIN_ROLE, + common.TENANT_ADMIN_ROLE, + ]) + ) { throw responses.failureResponse({ message: 'USER_IS_NOT_A_ADMIN', statusCode: httpStatusCode.bad_request, @@ -102,7 +117,12 @@ module.exports = class OrgAdmin { */ async getRequests(req) { try { - if (!utilsHelper.validateRoleAccess(req.decodedToken.roles, common.ORG_ADMIN_ROLE)) { + if ( + !utilsHelper.validateRoleAccess(req.decodedToken.roles, [ + common.ORG_ADMIN_ROLE, + common.TENANT_ADMIN_ROLE, + ]) + ) { throw responses.failureResponse({ message: 'USER_IS_NOT_A_ADMIN', statusCode: httpStatusCode.bad_request, @@ -129,7 +149,12 @@ module.exports = class OrgAdmin { */ async updateRequestStatus(req) { try { - if (!utilsHelper.validateRoleAccess(req.decodedToken.roles, common.ORG_ADMIN_ROLE)) { + if ( + !utilsHelper.validateRoleAccess(req.decodedToken.roles, [ + common.ORG_ADMIN_ROLE, + common.TENANT_ADMIN_ROLE, + ]) + ) { throw responses.failureResponse({ message: 'USER_IS_NOT_A_ADMIN', statusCode: httpStatusCode.bad_request, @@ -153,7 +178,12 @@ module.exports = class OrgAdmin { */ async deactivateUser(req) { try { - if (!utilsHelper.validateRoleAccess(req.decodedToken.roles, common.ORG_ADMIN_ROLE)) { + if ( + !utilsHelper.validateRoleAccess(req.decodedToken.roles, [ + common.ORG_ADMIN_ROLE, + common.TENANT_ADMIN_ROLE, + ]) + ) { throw responses.failureResponse({ message: 'USER_IS_NOT_A_ADMIN', statusCode: httpStatusCode.bad_request, @@ -178,7 +208,12 @@ module.exports = class OrgAdmin { async inheritEntityType(req) { try { - if (!utilsHelper.validateRoleAccess(req.decodedToken.roles, common.ORG_ADMIN_ROLE)) { + if ( + !utilsHelper.validateRoleAccess(req.decodedToken.roles, [ + common.ORG_ADMIN_ROLE, + common.TENANT_ADMIN_ROLE, + ]) + ) { throw responses.failureResponse({ message: 'USER_IS_NOT_A_ADMIN', statusCode: httpStatusCode.bad_request, diff --git a/src/controllers/v1/organization-feature.js b/src/controllers/v1/organization-feature.js index 10d3749f..da6f904d 100644 --- a/src/controllers/v1/organization-feature.js +++ b/src/controllers/v1/organization-feature.js @@ -89,7 +89,8 @@ module.exports = class OrganizationFeature { ) : await organizationFeatureService.list( req.decodedToken.tenant_code, - req.decodedToken.organization_code + req.decodedToken.organization_code, + req.decodedToken.roles ) } catch (error) { return error diff --git a/src/controllers/v1/organization.js b/src/controllers/v1/organization.js index e56eb6ea..dddc9a15 100644 --- a/src/controllers/v1/organization.js +++ b/src/controllers/v1/organization.js @@ -31,7 +31,7 @@ module.exports = class Organization { let isAdmin = false const roles = req.decodedToken.roles if (roles && roles.length > 0) { - isAdmin = utilsHelper.validateRoleAccess(roles, common.ADMIN_ROLE) + isAdmin = utilsHelper.validateRoleAccess(roles, [common.ADMIN_ROLE, common.TENANT_ADMIN_ROLE]) } if (!isAdmin) { @@ -68,7 +68,7 @@ module.exports = class Organization { if (roles && roles.length > 0) { isAdmin = utilsHelper.validateRoleAccess(roles, common.ADMIN_ROLE) - isOrgAdmin = utilsHelper.validateRoleAccess(roles, common.ORG_ADMIN_ROLE) + isOrgAdmin = utilsHelper.validateRoleAccess(roles, [common.ORG_ADMIN_ROLE, common.TENANT_ADMIN_ROLE]) } if (req.params.id != req.decodedToken.organization_id && isOrgAdmin) { @@ -170,6 +170,7 @@ module.exports = class Organization { isAdmin = utilsHelper.validateRoleAccess(roles, common.ADMIN_ROLE) || utilsHelper.validateRoleAccess(roles, common.ORG_ADMIN_ROLE) || + utilsHelper.validateRoleAccess(roles, common.TENANT_ADMIN_ROLE) || false } const result = await orgService.details( @@ -215,7 +216,7 @@ module.exports = class Organization { if (roles && roles.length > 0) { isAdmin = utilsHelper.validateRoleAccess(roles, common.ADMIN_ROLE) - isOrgAdmin = utilsHelper.validateRoleAccess(roles, common.ORG_ADMIN_ROLE) + isOrgAdmin = utilsHelper.validateRoleAccess(roles, [common.ORG_ADMIN_ROLE, common.TENANT_ADMIN_ROLE]) } if (!isAdmin && !isOrgAdmin) { throw responses.failureResponse({ @@ -260,7 +261,7 @@ module.exports = class Organization { if (roles && roles.length > 0) { isAdmin = utilsHelper.validateRoleAccess(roles, common.ADMIN_ROLE) - isOrgAdmin = utilsHelper.validateRoleAccess(roles, common.ORG_ADMIN_ROLE) + isOrgAdmin = utilsHelper.validateRoleAccess(roles, [common.ORG_ADMIN_ROLE, common.TENANT_ADMIN_ROLE]) } if (!isAdmin && !isOrgAdmin) { throw responses.failureResponse({ diff --git a/src/database/migrations/20251002164809-create-feature-role-mapping-table.js b/src/database/migrations/20251002164809-create-feature-role-mapping-table.js new file mode 100644 index 00000000..49ae14b7 --- /dev/null +++ b/src/database/migrations/20251002164809-create-feature-role-mapping-table.js @@ -0,0 +1,61 @@ +'use strict' + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('feature_role_mapping', { + id: { + type: Sequelize.INTEGER, + allowNull: false, + autoIncrement: true, + }, + feature_code: { + type: Sequelize.STRING, + allowNull: false, + }, + role_title: { + type: Sequelize.STRING, + allowNull: false, + }, + organization_code: { + type: Sequelize.STRING, + allowNull: false, + }, + tenant_code: { + type: Sequelize.STRING, + allowNull: false, + }, + created_by: { + type: Sequelize.INTEGER, + allowNull: false, + }, + updated_by: { + type: Sequelize.INTEGER, + allowNull: true, + }, + created_at: { + allowNull: false, + type: Sequelize.DATE, + }, + updated_at: { + allowNull: false, + type: Sequelize.DATE, + }, + deleted_at: { + allowNull: true, + type: Sequelize.DATE, + }, + }) + + // Add composite primary key + await queryInterface.addConstraint('feature_role_mapping', { + fields: ['id', 'tenant_code'], + type: 'primary key', + name: 'pk_feature_role_mapping', + }) + }, + + async down(queryInterface) { + await queryInterface.dropTable('feature_role_mapping') + }, +} diff --git a/src/database/migrations/20251002164938-add-new-feature.js b/src/database/migrations/20251002164938-add-new-feature.js new file mode 100644 index 00000000..de0ca030 --- /dev/null +++ b/src/database/migrations/20251002164938-add-new-feature.js @@ -0,0 +1,87 @@ +'use strict' + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Check if SCP feature already exists + const scpFeature = await queryInterface.sequelize.query("SELECT code FROM features WHERE code = 'scp'", { + type: Sequelize.QueryTypes.SELECT, + }) + + // Insert SCP feature if it doesn't exist + if (scpFeature.length === 0) { + await queryInterface.bulkInsert('features', [ + { + code: 'scp', + label: 'Self Creation Portal', + display_order: 9, + description: 'SCP capability', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + } + + // Get all organizations + const tenants = await queryInterface.sequelize.query('SELECT code FROM tenants WHERE deleted_at IS NULL', { + type: Sequelize.QueryTypes.SELECT, + }) + + // Get default orgs for each organization + const organizationFeatureData = [] + for (const tenant of tenants) { + const orgExist = await queryInterface.sequelize.query( + 'SELECT code, tenant_code FROM organizations WHERE deleted_at IS NULL AND tenant_code = :tenantCode AND code = :orgCode', + { + replacements: { + tenantCode: tenant.code, + orgCode: process.env.DEFAULT_ORGANISATION_CODE || 'default_code', + }, + type: Sequelize.QueryTypes.SELECT, + } + ) + + if (orgExist.length > 0) { + const orgFeatureExist = await queryInterface.sequelize.query( + 'SELECT organization_code FROM organization_features WHERE tenant_code = :tenantCode AND organization_code = :orgCode AND feature_code = :featureCode', + { + replacements: { + tenantCode: tenant.code, + orgCode: process.env.DEFAULT_ORGANISATION_CODE || 'default_code', + featureCode: 'scp', + }, + type: Sequelize.QueryTypes.SELECT, + } + ) + if (orgFeatureExist.length === 0) { + organizationFeatureData.push({ + organization_code: process.env.DEFAULT_ORGANISATION_CODE || 'default_code', + tenant_code: tenant.code, + feature_code: 'scp', + feature_name: 'SCP', + enabled: true, + display_order: 9, + created_at: new Date(), + updated_at: new Date(), + }) + } + } + } + + if (organizationFeatureData.length > 0) { + await queryInterface.bulkInsert('organization_features', organizationFeatureData) + } + }, + + async down(queryInterface) { + // Remove organization_feature records only for the SCP feature + await queryInterface.bulkDelete('organization_features', { + feature_code: 'scp', + }) + + // Remove SCP feature + await queryInterface.bulkDelete('features', { + code: 'scp', + }) + }, +} diff --git a/src/database/migrations/20251002165109-map-roles-to-features.js b/src/database/migrations/20251002165109-map-roles-to-features.js new file mode 100644 index 00000000..48bb5d1c --- /dev/null +++ b/src/database/migrations/20251002165109-map-roles-to-features.js @@ -0,0 +1,139 @@ +'use strict' + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Fetch all enabled features for all organizations from the `organization_features` table. + const organizationsFeatures = await queryInterface.sequelize.query( + 'SELECT feature_code, tenant_code, organization_code FROM organization_features WHERE deleted_at IS NULL', + { type: Sequelize.QueryTypes.SELECT } + ) + + // Fetch all possible features available in the system. + const allFeatures = await queryInterface.sequelize.query('SELECT code FROM features', { + type: Sequelize.QueryTypes.SELECT, + }) + // Define a baseline set of default features. + let defaultFeatures = ['project', 'mentoring', 'survey', 'observation', 'reports', 'mitra', 'programs'] + // Define the feature set for each user role. This is the core mapping logic. + const roleMappings = { + content_creator: ['scp', ...defaultFeatures], + reviewer: ['scp', 'learn', ...defaultFeatures], + creator: ['scp', 'learn', ...defaultFeatures], + rollout_manager: ['scp', ...defaultFeatures], + program_manager: ['scp', ...defaultFeatures], + program_designer: ['scp', ...defaultFeatures], + learner: ['learn', ...defaultFeatures], + mentee: defaultFeatures, + mentor: defaultFeatures, + session_manager: defaultFeatures, + report_admin: ['reports', 'learn', ...defaultFeatures], + state_manager: ['reports', 'learn', ...defaultFeatures], + district_manager: ['reports', 'learn', ...defaultFeatures], + admin: allFeatures.map((f) => f.code), // Admins get all features. + org_admin: allFeatures.map((f) => f.code), // Org admins also get all features. + tenant_admin: allFeatures.map((f) => f.code), // Tenant admins also get all features. + } + + // Create a lookup map (`orgFeaturesMap`) for efficient access to enabled features per organization. + const orgFeaturesMap = new Map() + organizationsFeatures.forEach((item) => { + const orgKey = `${item.organization_code}|${item.tenant_code}` + if (!orgFeaturesMap.has(orgKey)) { + orgFeaturesMap.set(orgKey, new Set()) + } + orgFeaturesMap.get(orgKey).add(item.feature_code) + }) + + // Extract unique organizations + const uniqueOrgsMap = new Map() + organizationsFeatures.forEach((item) => { + const key = `${item.organization_code}|${item.tenant_code}` + if (!uniqueOrgsMap.has(key)) { + uniqueOrgsMap.set(key, { + organization_code: item.organization_code, + tenant_code: item.tenant_code, + }) + } + }) + const uniqueOrgs = Array.from(uniqueOrgsMap.values()) + + console.log(`Total organization_features rows: ${organizationsFeatures.length}`) + console.log(`Unique organizations: ${uniqueOrgs.length}`) + + // Prepare arrays to collect mappings and track duplicates/skipped + const featureRoleMappingData = [] + const seenMappings = new Set() + const skippedMappings = [] + + // Main loop: Process each organization to create feature-role mappings + for (const org of uniqueOrgs) { + const orgKey = `${org.organization_code}|${org.tenant_code}` + const enabledFeatures = orgFeaturesMap.get(orgKey) || new Set() + + // Fetch roles available for this tenant + const tenantRoles = await queryInterface.sequelize.query( + 'SELECT title FROM user_roles WHERE tenant_code = ? AND deleted_at IS NULL', + { + replacements: [org.tenant_code], + type: Sequelize.QueryTypes.SELECT, + } + ) + const tenantRoleTitles = tenantRoles.map((role) => role.title) + + // Loop through each role and its features + for (const [role, features] of Object.entries(roleMappings)) { + if (tenantRoleTitles.includes(role)) { + for (const featureCode of features) { + const key = `${featureCode}|${role}|${org.organization_code}|${org.tenant_code}` + + // ✅ Check if feature exists in organization_features + if (!enabledFeatures.has(featureCode)) { + skippedMappings.push({ + reason: 'Feature not enabled for org', + feature: featureCode, + role: role, + org: org.organization_code, + tenant: org.tenant_code, + }) + continue // Skip this mapping + } + + if (!seenMappings.has(key)) { + seenMappings.add(key) + featureRoleMappingData.push({ + role_title: role, + feature_code: featureCode, + organization_code: org.organization_code, + tenant_code: org.tenant_code, + created_by: 0, + updated_by: 0, + created_at: new Date(), + updated_at: new Date(), + }) + } + } + } + } + } + + console.log(`Total mappings to insert: ${featureRoleMappingData.length}`) + console.log(`Skipped mappings: ${skippedMappings.length}`) + + // Log first few skipped for debugging + if (skippedMappings.length > 0) { + console.log('Sample skipped mappings:') + console.log(skippedMappings.slice(0, 10)) + } + + // Insert all valid mappings into the database + if (featureRoleMappingData.length > 0) { + await queryInterface.bulkInsert('feature_role_mapping', featureRoleMappingData) + } + }, + + // Revert the migration by deleting all feature-role mappings + async down(queryInterface) { + await queryInterface.bulkDelete('feature_role_mapping', null, {}) + }, +} diff --git a/src/database/migrations/20251003155747-add-feature-role-mapping-constraints.js b/src/database/migrations/20251003155747-add-feature-role-mapping-constraints.js new file mode 100644 index 00000000..3157f3fb --- /dev/null +++ b/src/database/migrations/20251003155747-add-feature-role-mapping-constraints.js @@ -0,0 +1,80 @@ +'use strict' + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Add foreign key constraint for feature_code + await queryInterface.addConstraint('feature_role_mapping', { + fields: ['feature_code'], + type: 'foreign key', + name: 'fk_feature_role_mapping_feature_code', + references: { + table: 'features', + field: 'code', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }) + + // Add foreign key constraint for tenant_code + await queryInterface.addConstraint('feature_role_mapping', { + fields: ['tenant_code'], + type: 'foreign key', + name: 'fk_feature_role_mapping_tenant_code', + references: { + table: 'tenants', + field: 'code', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }) + + // Add composite foreign key for organization_code (organization_code, tenant_code) -> organizations (code, tenant_code) + await queryInterface.sequelize.query(` + ALTER TABLE feature_role_mapping + ADD CONSTRAINT fk_feature_role_mapping_organization_code + FOREIGN KEY (organization_code, tenant_code) + REFERENCES organizations (code, tenant_code) + ON UPDATE CASCADE + ON DELETE CASCADE; + `) + + // Add composite foreign key for role_title (tenant_code, role_title) -> user_roles (tenant_code, title) + //commenting this as of now because of issue in user_roles table which is using the organization_id as foreign key + // await queryInterface.sequelize.query(` + // ALTER TABLE feature_role_mapping + // ADD CONSTRAINT fk_feature_role_mapping_role_title + // FOREIGN KEY (tenant_code, role_title) + // REFERENCES user_roles (tenant_code, title) + // ON UPDATE CASCADE + // ON DELETE NO ACTION; + // `) + + // Unique constraint for feature_code, role_title, organization_code, tenant_code + await queryInterface.sequelize.query(` + CREATE UNIQUE INDEX feature_role_org_tenant_unique + ON feature_role_mapping (feature_code, role_title, organization_code, tenant_code) + WHERE deleted_at IS NULL; + `) + + await queryInterface.sequelize.query(` + ALTER TABLE feature_role_mapping + ADD CONSTRAINT fk_org_feature_role_mapping_organization_code + FOREIGN KEY (feature_code, tenant_code, organization_code) + REFERENCES organization_features (feature_code, tenant_code, organization_code) + ON UPDATE CASCADE + ON DELETE CASCADE; + `) + }, + + async down(queryInterface, Sequelize) { + // Drop foreign key constraints + await queryInterface.removeConstraint('feature_role_mapping', 'fk_feature_role_mapping_tenant_code') + await queryInterface.removeConstraint('feature_role_mapping', 'fk_feature_role_mapping_organization_code') + // await queryInterface.removeConstraint('feature_role_mapping', 'fk_feature_role_mapping_role_title') + await queryInterface.removeConstraint('feature_role_mapping', 'fk_feature_role_mapping_feature_code') + await queryInterface.removeConstraint('feature_role_mapping', 'fk_org_feature_role_mapping_organization_code') + + await queryInterface.sequelize.query('DROP INDEX IF EXISTS feature_role_org_tenant_unique;') + }, +} diff --git a/src/database/migrations/20251022160602-add-tenant-admin-role.js b/src/database/migrations/20251022160602-add-tenant-admin-role.js new file mode 100644 index 00000000..7c1fe37f --- /dev/null +++ b/src/database/migrations/20251022160602-add-tenant-admin-role.js @@ -0,0 +1,139 @@ +'use strict' + +module.exports = { + up: async (queryInterface, Sequelize) => { + const transaction = await queryInterface.sequelize.transaction() + + try { + // Step 1: Get all unique organization_ids per tenant from existing user_roles + const [orgsByTenant] = await queryInterface.sequelize.query( + `SELECT DISTINCT tenant_code, organization_id + FROM user_roles + WHERE deleted_at IS NULL + AND tenant_code IN ( + SELECT code FROM tenants WHERE deleted_at IS NULL + ) + ORDER BY tenant_code, organization_id`, + { transaction } + ) + + if (orgsByTenant.length === 0) { + console.log('No active organizations found. Skipping migration.') + await transaction.commit() + return + } + + console.log(`Found ${orgsByTenant.length} organization-tenant combinations`) + + // Step 2: Insert tenant_admin role for each tenant-organization combination + const userRoleInserts = orgsByTenant.map((org) => ({ + title: 'tenant_admin', + label: 'Tenant Admin', + user_type: 1, // Adjust this value based on your user_type convention + status: 'ACTIVE', + organization_id: org.organization_id, + visibility: 'PUBLIC', + tenant_code: org.tenant_code, + translations: null, + created_at: new Date(), + updated_at: new Date(), + })) + + await queryInterface.bulkInsert('user_roles', userRoleInserts, { + transaction, + ignoreDuplicates: true, // In case role already exists + }) + + console.log(`Inserted tenant_admin role for ${userRoleInserts.length} organization-tenant combinations`) + + // Step 3: Get all admin permissions except admin module and admin-only permissions + // Excluding: + // - module = 'admin' (permission_ids: 22, 23, 26) + // - Admin-only feature permission (40) + // - Admin-only tenant permission (35) + // Including organization permissions (8, 28, 29, 30) as per requirement + const [adminPermissions] = await queryInterface.sequelize.query( + `SELECT DISTINCT + permission_id, + module, + request_type, + api_path, + created_at, + updated_at, + created_by + FROM role_permission_mapping + WHERE role_title = 'admin' + AND module != 'admin' + AND permission_id NOT IN (35, 40) + ORDER BY permission_id`, + { transaction } + ) + + console.log(`Found ${adminPermissions.length} permissions to copy for tenant_admin`) + + // Step 4: Insert permissions for tenant_admin role + if (adminPermissions.length > 0) { + const permissionInserts = adminPermissions.map((perm) => ({ + role_title: 'tenant_admin', + permission_id: perm.permission_id, + module: perm.module, + request_type: perm.request_type, + api_path: perm.api_path, + created_at: new Date(), + updated_at: new Date(), + created_by: perm.created_by, + })) + + await queryInterface.bulkInsert('role_permission_mapping', permissionInserts, { + transaction, + ignoreDuplicates: true, + }) + + console.log(`Inserted ${permissionInserts.length} permissions for tenant_admin role`) + } + + // Commit transaction + await transaction.commit() + console.log('Migration completed successfully') + console.log('Summary:') + console.log(`- Created tenant_admin roles: ${userRoleInserts.length}`) + console.log(`- Assigned permissions: ${adminPermissions.length}`) + console.log('- Excluded modules: admin') + console.log('- Excluded permissions: 35 (tenant), 40 (feature full CRUD)') + } catch (error) { + // Rollback transaction on error + await transaction.rollback() + console.error('Migration failed, rolled back:', error) + throw error + } + }, + + down: async (queryInterface, Sequelize) => { + const transaction = await queryInterface.sequelize.transaction() + + try { + // Step 1: Delete all tenant_admin permissions from role_permission_mapping + const [deletePermResult] = await queryInterface.sequelize.query( + `DELETE FROM role_permission_mapping WHERE role_title = 'tenant_admin'`, + { transaction } + ) + + console.log('Deleted all tenant_admin permissions') + + // Step 2: Delete all tenant_admin roles from user_roles (soft delete if paranoid) + const [deleteRoleResult] = await queryInterface.sequelize.query( + `DELETE FROM user_roles WHERE title = 'tenant_admin'`, + { transaction } + ) + + console.log('Deleted all tenant_admin roles') + + await transaction.commit() + console.log('Rollback completed successfully') + } catch (error) { + await transaction.rollback() + console.error('Rollback failed:', error) + throw error + } + }, +} diff --git a/src/database/models/Feature.js b/src/database/models/Feature.js index e098c428..a5d20a87 100644 --- a/src/database/models/Feature.js +++ b/src/database/models/Feature.js @@ -62,6 +62,11 @@ module.exports = (sequelize, DataTypes) => { foreignKey: 'feature_code', as: 'organization_features', }) + Feature.hasMany(models.FeatureRoleMapping, { + foreignKey: 'feature_code', + sourceKey: 'code', + as: 'featureRoleMappings', + }) } return Feature diff --git a/src/database/models/featureRoleMapping.js b/src/database/models/featureRoleMapping.js new file mode 100644 index 00000000..7bafade2 --- /dev/null +++ b/src/database/models/featureRoleMapping.js @@ -0,0 +1,93 @@ +module.exports = (sequelize, DataTypes) => { + const FeatureRoleMapping = sequelize.define( + 'FeatureRoleMapping', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + role_title: { + type: DataTypes.STRING, + allowNull: false, + }, + feature_code: { + type: DataTypes.STRING, + allowNull: false, + }, + organization_code: { + type: DataTypes.STRING, + allowNull: false, + }, + tenant_code: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true, + }, + created_by: { + type: DataTypes.INTEGER, + allowNull: true, + }, + updated_by: { + type: DataTypes.INTEGER, + allowNull: true, + }, + }, + { + modelName: 'FeatureRoleMapping', + tableName: 'feature_role_mapping', + freezeTableName: true, + paranoid: false, + indexes: [ + { + unique: true, + fields: ['feature_code', 'role_title', 'organization_code', 'tenant_code'], + where: { + deleted_at: null, + }, + }, + ], + } + ) + + FeatureRoleMapping.associate = function (models) { + // Association with Feature model + FeatureRoleMapping.belongsTo(models.Feature, { + foreignKey: 'feature_code', + targetKey: 'code', + as: 'feature', + }) + + // Association with UserRole model + // FeatureRoleMapping.belongsTo(models.UserRole, { + // foreignKey: { + // name: 'role_title', + // allowNull: false, + // }, + // targetKey: 'title', + // constraints: false, + // as: 'userRole', + // }) + + // Association with Organization model + FeatureRoleMapping.belongsTo(models.Organization, { + foreignKey: { + name: 'organization_code', + allowNull: false, + }, + targetKey: 'code', + constraints: false, + as: 'organization', + }) + + // Association with Tenant model + FeatureRoleMapping.belongsTo(models.Tenant, { + foreignKey: 'tenant_code', + targetKey: 'code', + as: 'tenant', + }) + } + + return FeatureRoleMapping +} diff --git a/src/database/queries/featureRoleMapping.js b/src/database/queries/featureRoleMapping.js new file mode 100644 index 00000000..21f59e2e --- /dev/null +++ b/src/database/queries/featureRoleMapping.js @@ -0,0 +1,88 @@ +/** + * name : featureRoleMapping.js + * author : Priyanka Pradeep + * created-date : 02-Oct-2025 + * Description : feature-role-mapping database operations. + */ + +const FeatureRoleMapping = require('@database/models/index').FeatureRoleMapping + +module.exports = class FeatureRoleMappingQueries { + /** + * Create feature role mapping. + * @method + * @name create + * @param {Object} data + * @returns {Promise} + */ + static async create(data) { + try { + const result = await FeatureRoleMapping.create(data, { + returning: true, + raw: true, + }) + return result + } catch (error) { + throw error + } + } + + /** + * Bulk create feature role mappings. + * @method + * @name bulkCreate + * @param {Array} data + * @returns {Promise} + */ + static async bulkCreate(data) { + try { + const result = await FeatureRoleMapping.bulkCreate(data, { + returning: true, + raw: true, + }) + return result + } catch (error) { + throw error + } + } + + /** + * Find feature role mappings by user roles with relations. + * @method + * @name findByRoles + * @param {Array} roles - Array of role titles + * @param {String} organizationCode + * @param {String} tenantCode + * @returns {Promise} + */ + static async findAll(filter, options = {}) { + try { + const result = await FeatureRoleMapping.findAll({ + where: filter, + ...options, + raw: true, + }) + return result + } catch (error) { + throw error + } + } + + /** + * Delete feature role mapping. + * @method + * @name delete + * @param {Object} filter + * @returns {Promise} + */ + static async delete(filter) { + try { + const result = await FeatureRoleMapping.destroy({ + where: filter, + }) + return result + } catch (error) { + throw error + } + } +} diff --git a/src/database/queries/notificationTemplate.js b/src/database/queries/notificationTemplate.js index 7aed3e72..2e01c18d 100644 --- a/src/database/queries/notificationTemplate.js +++ b/src/database/queries/notificationTemplate.js @@ -83,14 +83,22 @@ exports.findOneEmailTemplate = async (code, orgCode = null, tenantCode) => { templateData = templateData.find((template) => template.organization_code === orgCode) || templateData[0] if (templateData && templateData.email_header) { - const header = await this.getEmailHeader(templateData.email_header) + const header = await this.getEmailHeader( + templateData.email_header, + templateData.tenant_code, + templateData.organization_code + ) if (header && header.body) { templateData['body'] = header.body + templateData['body'] } } if (templateData && templateData.email_footer) { - const footer = await this.getEmailFooter(templateData.email_footer) + const footer = await this.getEmailFooter( + templateData.email_footer, + templateData.tenant_code, + templateData.organization_code + ) if (footer && footer.body) { templateData['body'] = templateData['body'] + footer.body } @@ -101,12 +109,14 @@ exports.findOneEmailTemplate = async (code, orgCode = null, tenantCode) => { } } -exports.getEmailHeader = async (header) => { +exports.getEmailHeader = async (header, tenantCode, organizationCode) => { try { const filterEmailHeader = { code: header, type: 'emailHeader', status: common.ACTIVE_STATUS, + tenant_code: tenantCode, + organization_code: organizationCode, } const headerData = await NotificationTemplate.findOne({ @@ -119,12 +129,14 @@ exports.getEmailHeader = async (header) => { } } -exports.getEmailFooter = async (footer) => { +exports.getEmailFooter = async (footer, tenantCode, organizationCode) => { try { const filterEmailFooter = { code: footer, type: 'emailFooter', status: common.ACTIVE_STATUS, + tenant_code: tenantCode, + organization_code: organizationCode, } const headerData = await NotificationTemplate.findOne({ diff --git a/src/database/queries/organization-feature.js b/src/database/queries/organization-feature.js index bc55dc44..090119c8 100644 --- a/src/database/queries/organization-feature.js +++ b/src/database/queries/organization-feature.js @@ -1,5 +1,7 @@ 'use strict' const OrganizationFeature = require('@database/models/index').OrganizationFeature +const FeatureRoleMapping = require('@database/models/index').FeatureRoleMapping +const { Op } = require('sequelize') exports.create = async (data) => { try { @@ -50,6 +52,30 @@ exports.findAllOrganizationFeature = async (filter, options = {}) => { } } +exports.findAllFeatureWithRoleMappings = async (filter, options = {}, roleTitles = []) => { + try { + const organizationFeature = await OrganizationFeature.findAll({ + where: filter, + ...options, + include: [ + { + model: FeatureRoleMapping, + as: 'roleMappings', + ...(roleTitles?.length > 0 && { + where: { role_title: { [Op.in]: roleTitles } }, + }), + required: false, + }, + ], + raw: false, + nest: true, + }) + return organizationFeature + } catch (error) { + return error + } +} + exports.hardDelete = async (feature_code, organization_code, tenant_code) => { try { return await OrganizationFeature.destroy({ diff --git a/src/database/queries/users.js b/src/database/queries/users.js index 75a004fd..ed2e6d1c 100644 --- a/src/database/queries/users.js +++ b/src/database/queries/users.js @@ -98,7 +98,10 @@ exports.listUsers = async (roleId, organization_id, page, limit, search, tenant_ // Final query using the updated schema let { count, rows: users } = await database.User.findAndCountAll({ - where: userWhereClause, + where: { + ...userWhereClause, + tenant_code, // Ensure this is in the main where clause + }, attributes: ['id', 'name', 'about', 'image'], offset: parseInt(offset, 10), limit: parseInt(limit, 10), diff --git a/src/distributionColumns.sql b/src/distributionColumns.sql index e573ff3a..a1f466a4 100644 --- a/src/distributionColumns.sql +++ b/src/distributionColumns.sql @@ -1,37 +1,69 @@ create extension citus -- Step 1: Create reference tables first -SELECT create_reference_table('features'); +SELECT create_reference_table ('features'); -- Step 2: Distribute primary, top-level tenant-scoped tables early -SELECT create_distributed_table('tenants','code'); -SELECT create_distributed_table('users', 'tenant_code'); -SELECT create_distributed_table('organizations', 'tenant_code'); -SELECT create_distributed_table('entity_types', 'tenant_code'); +SELECT create_distributed_table ('tenants', 'code'); + +SELECT create_distributed_table ('users', 'tenant_code'); + +SELECT create_distributed_table ( 'organizations', 'tenant_code' ); + +SELECT create_distributed_table ('entity_types', 'tenant_code'); -- Step 3: Then distribute dependent tables (ensure matching shard keys!) -SELECT create_distributed_table('entities', 'tenant_code'); -SELECT create_distributed_table('forms', 'tenant_code'); -SELECT create_distributed_table('notification_templates', 'tenant_code'); -SELECT create_distributed_table('file_uploads', 'tenant_code'); -SELECT create_distributed_table('user_sessions', 'tenant_code'); +SELECT create_distributed_table ('entities', 'tenant_code'); + +SELECT create_distributed_table ('forms', 'tenant_code'); + +SELECT create_distributed_table ( + 'notification_templates', 'tenant_code' + ); + +SELECT create_distributed_table ('file_uploads', 'tenant_code'); + +SELECT create_distributed_table ( 'user_sessions', 'tenant_code' ); -- Organization-related tables -SELECT create_distributed_table('organization_email_domains', 'tenant_code'); -SELECT create_distributed_table('organization_features', 'tenant_code'); -SELECT create_distributed_table('organization_registration_codes', 'tenant_code'); +SELECT create_distributed_table ( + 'organization_email_domains', 'tenant_code' + ); + +SELECT create_distributed_table ( + 'organization_features', 'tenant_code' + ); + +SELECT create_distributed_table ( + 'organization_registration_codes', 'tenant_code' + ); -- Invitation / user-related -SELECT create_distributed_table('invitations','tenant_code'); -SELECT create_distributed_table('organization_user_invites','tenant_code'); +SELECT create_distributed_table ('invitations', 'tenant_code'); + +SELECT create_distributed_table ( + 'organization_user_invites', 'tenant_code' + ); + +SELECT create_distributed_table ('user_roles', 'tenant_code'); + +SELECT create_distributed_table ( + 'organization_role_requests', 'tenant_code' + ); -SELECT create_distributed_table('user_roles', 'tenant_code'); -SELECT create_distributed_table('organization_role_requests','tenant_code'); +SELECT create_distributed_table ( + 'user_organizations', 'tenant_code' + ); -SELECT create_distributed_table('user_organizations', 'tenant_code'); -SELECT create_distributed_table('user_organization_roles', 'tenant_code'); +SELECT create_distributed_table ( + 'user_organization_roles', 'tenant_code' + ); --SELECT create_distributed_table('users_credentials','email'); -- ⚠️ distribution by email will break colocation -- Tenant-domain related -SELECT create_distributed_table('tenant_domains','domain'); -- ⚠️ different colocation +SELECT create_distributed_table ('tenant_domains', 'domain'); +-- ⚠️ different colocation +SELECT create_distributed_table ( + 'feature_role_mapping', 'tenant_code' + ); \ No newline at end of file diff --git a/src/envVariables.js b/src/envVariables.js index 692b6dba..1e8a3208 100644 --- a/src/envVariables.js +++ b/src/envVariables.js @@ -2,6 +2,43 @@ let table = require('cli-table') let tableData = new table() +const databaseEnvironmentVariables = { + DB_POOL_MAX: { + message: 'Max connections per Sequelize pool', + optional: true, + default: 10, + }, + DB_POOL_MIN: { + message: 'Min connections per Sequelize pool', + optional: true, + default: 2, + }, + DB_POOL_ACQUIRE_MS: { + message: 'Pool acquire timeout (ms)', + optional: true, + default: 60000, + }, + DB_POOL_IDLE_MS: { + message: 'Connection idle timeout (ms)', + optional: true, + default: 10000, + }, + DB_POOL_EVICT_MS: { + message: 'Pool eviction interval (ms)', + optional: true, + default: 1000, + }, + PG_STATEMENT_TIMEOUT_MS: { + message: 'Max query execution time (ms)', + optional: true, + default: 0, + }, + PG_IDLE_TX_TIMEOUT_MS: { + message: 'Idle transaction timeout (ms)', + optional: true, + default: 0, + }, +} let enviromentVariables = { APPLICATION_PORT: { message: 'Required port no', @@ -29,6 +66,7 @@ let enviromentVariables = { optional: true, default: 'MentorED', }, + ...databaseEnvironmentVariables, REGISTRATION_EMAIL_TEMPLATE_CODE: { message: 'Required registration email template code', optional: true, diff --git a/src/helpers/userInvite.js b/src/helpers/userInvite.js index 0adf8088..3f11a31c 100644 --- a/src/helpers/userInvite.js +++ b/src/helpers/userInvite.js @@ -861,10 +861,10 @@ module.exports = class UserInviteHelper { ) : {} // fetch the user with organization and roles if any of the critical fields are updated userFetch = userFetch?.[0] || {} + oldValues.organizations = existingUser.organizations // if org or role is updated assign the old and new values if (isOrgUpdate || isRoleUpdated) { - oldValues.organizations = existingUser.organizations newValues.organizations = userFetch?.organizations } // if any keys are modified in user table or additional csv headers are present prepare the new values @@ -915,7 +915,7 @@ module.exports = class UserInviteHelper { if (oldValues?.phone) { oldValues.phone = emailEncryption.decrypt(oldValues.phone) } - if (Object.keys(oldValues).length > 0 || Object.keys(newValues).length > 0) { + if (Object.keys(newValues).length > 0) { const eventBody = eventBodyDTO({ entity: 'user', eventType: 'bulk-update', diff --git a/src/locales/en.json b/src/locales/en.json index 412b6b2a..5a864343 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -191,6 +191,12 @@ "QUERY_FORBIDDEN_INJECTION_PATTERNS": "Query contains forbidden injection patterns.", "QUERY_FORBIDDEN_PATTERNS": "Query contains forbidden SQL operations.", "ADD_ORG_HEADER": "Please provide all required organization headers: {{orgCodeHeader}}, and {{tenantCodeHeader}} for admin override.", + "ORG_CODE_REQUIRED_FOR_TENANT_ADMIN": "Please provide header {{orgCodeHeader}} for admin override.", "INVALID_ORG_ID": "Organization ID must be a valid positive integer.", - "INVALID_ORG_OR_TENANT_CODE": "The provided organization or tenant code is invalid or does not match." + "INVALID_ORG_OR_TENANT_CODE": "The provided organization or tenant code is invalid or does not match.", + "INVALID_ORG_CODE_FOR_TENANT": "The provided organization is invalid or does not match.", + "USER_ORGANIZATION_NOT_FOUND": "The user is not associated with the specified organization.", + "USER_ROLE_ALREADY_EXISTS": "The user already has this role assigned.", + "INVALID_ROLE_ID": "The provided role ID is invalid.", + "USER_ROLE_ASSIGNED_SUCCESSFULLY": "The role was assigned to the user successfully." } diff --git a/src/middlewares/authenticator.js b/src/middlewares/authenticator.js index bb5063d0..912d1a81 100644 --- a/src/middlewares/authenticator.js +++ b/src/middlewares/authenticator.js @@ -203,67 +203,108 @@ module.exports = async function (req, res, next) { } if (!decodedToken) throw unAuthorizedResponse - //check for admin user + // Check for admin and tenant admin roles let isAdmin = false + let isTenantAdmin = false if (decodedToken.data.roles) { isAdmin = decodedToken.data.roles.some((role) => role.title === common.ADMIN_ROLE) + isTenantAdmin = decodedToken.data.roles.some((role) => role.title === common.TENANT_ADMIN_ROLE) } - if (isAdmin) { - // For admin users, allow overriding tenant_code and organization_code via headers - // Header names are configurable via environment variables with sensible defaults + // Handle organization override for admin and tenant admin + if (isAdmin || isTenantAdmin) { const orgCodeHeaderName = common.ORG_CODE_HEADER const tenantCodeHeaderName = common.TENANT_CODE_HEADER - // Extract and sanitize header values (trim whitespace, case-insensitive header lookup) const orgCode = (req.headers[orgCodeHeaderName.toLowerCase()] || '').trim() const tenantCode = (req.headers[tenantCodeHeaderName.toLowerCase()] || '').trim() - // If any override header is provided (non-empty after trim), both must be present and non-empty const hasAnyOverrideHeader = orgCode || tenantCode + if (hasAnyOverrideHeader) { - if (!orgCode || !tenantCode) { - throw responses.failureResponse({ - message: { - key: 'ADD_ORG_HEADER', - interpolation: { - orgCodeHeader: orgCodeHeaderName, - tenantCodeHeader: tenantCodeHeaderName, + // For tenant admin, always use their token's tenant_code (ignore any header value) + if (isTenantAdmin && !isAdmin) { + // Always use tenant_code from token, ignoring any header value for security + const effectiveTenantCode = decodedToken.data.tenant_code + + if (!orgCode) { + throw responses.failureResponse({ + message: { + key: 'ORG_CODE_REQUIRED_FOR_TENANT_ADMIN', + interpolation: { + orgCodeHeader: orgCodeHeaderName, + }, }, - }, - statusCode: httpStatusCode.bad_request, - responseCode: 'CLIENT_ERROR', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + + // Query the database to find the organization within tenant admin's tenant + const overrideOrg = await organizationQueries.findOne({ + code: orgCode, + tenant_code: effectiveTenantCode, + status: common.ACTIVE_STATUS, + deleted_at: null, }) - } - // Query the database to find the organization based on orgCode and tenantCode - const org = await organizationQueries.findOne({ - code: orgCode, - tenant_code: tenantCode, - status: common.ACTIVE_STATUS, - deleted_at: null, - }) + if (!overrideOrg) { + throw responses.failureResponse({ + message: 'INVALID_ORG_CODE_FOR_TENANT', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } - if (!org) { - throw responses.failureResponse({ - message: 'INVALID_ORG_OR_TENANT_CODE', - statusCode: httpStatusCode.bad_request, - responseCode: 'CLIENT_ERROR', + // Override organization details + decodedToken.data.organization_id = overrideOrg.id + decodedToken.data.organization_code = orgCode + // tenant_code remains unchanged for tenant admin + } else if (isAdmin) { + // Admin logic - can override both tenant and org + if (!orgCode || !tenantCode) { + throw responses.failureResponse({ + message: { + key: 'ADD_ORG_HEADER', + interpolation: { + orgCodeHeader: orgCodeHeaderName, + tenantCodeHeader: tenantCodeHeaderName, + }, + }, + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + + const overrideOrg = await organizationQueries.findOne({ + code: orgCode, + tenant_code: tenantCode, + status: common.ACTIVE_STATUS, + deleted_at: null, }) - } - // Override the values from the token with sanitized header values and fetched orgId - decodedToken.data.tenant_code = tenantCode - decodedToken.data.organization_id = org.id // Use the ID from the database - decodedToken.data.organization_code = orgCode + if (!overrideOrg) { + throw responses.failureResponse({ + message: 'INVALID_ORG_OR_TENANT_CODE', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + + // Override both tenant and organization details + decodedToken.data.tenant_code = tenantCode + decodedToken.data.organization_id = overrideOrg.id + decodedToken.data.organization_code = orgCode + } } - req.decodedToken = decodedToken.data - // Admin users intentionally bypass role and permission validation below. - // This early return ensures admins proceed without further checks. - // If admin access rules change, remove or adjust this bypass accordingly. + req.decodedToken = decodedToken.data - return next() + // Only admin users bypass role and permission validation + // Tenant admins must go through permission checks + if (isAdmin) { + return next() + } } if (roleValidation) { diff --git a/src/package.json b/src/package.json index a942204d..10b60fc5 100644 --- a/src/package.json +++ b/src/package.json @@ -16,8 +16,9 @@ "elevate-migrations": "module/migrations/bin/migrations.js", "integration": "node app.js", "test:integration": "jest --verbose ./integration-test --config=integrationJest.config.js --runInBand", - "db:init": "sequelize-cli db:create || echo 'Database already exists or some issue while creating db, Please check' && sequelize-cli db:migrate ", - "db:seed:all": "sequelize-cli db:seed:all || echo 'Seeded data already exists or some issue while seeding the data, Please check' " + "db:init": "sequelize-cli db:create || echo 'Database already exists or some issue while creating db, Please check'; sequelize-cli db:migrate", + "db:seed:all": "sequelize-cli db:seed:all || (echo 'Seeded data already exists or some issue while seeding the data, Please check' && false)", + "db:migrate": "sequelize-cli db:migrate" }, "author": "Aman Kumar Gupta ", "license": "ISC", diff --git a/src/services/account.js b/src/services/account.js index 02d02d98..88d9b71e 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -1608,7 +1608,7 @@ module.exports = class AccountHelper { }) } else { let role = await roleQueries.findOne( - { title: params.query.type.toLowerCase() }, + { title: params.query.type.toLowerCase(), tenant_code: tenantCode }, { attributes: ['id'], } diff --git a/src/services/admin.js b/src/services/admin.js index 133c8dbb..d038f8b1 100644 --- a/src/services/admin.js +++ b/src/services/admin.js @@ -18,6 +18,7 @@ const utils = require('@generics/utils') const rawQueryUtils = require('@utils/rawQueryUtils') // Database queries +const { ForeignKeyConstraintError, UniqueConstraintError } = require('sequelize') const organizationQueries = require('@database/queries/organization') const roleQueries = require('@database/queries/user-role') const tenantQueries = require('@database/queries/tenants') @@ -35,6 +36,7 @@ const userSessionsService = require('@services/user-sessions') const { broadcastEvent } = require('@helpers/eventBroadcasterMain') const emailEncryption = require('@utils/emailEncryption') const { eventBroadcaster } = require('@helpers/eventBroadcaster') + const { generateUniqueUsername } = require('@utils/usernameGenerator') const responses = require('@helpers/responses') const userHelper = require('@helpers/userHelper') @@ -42,7 +44,7 @@ const userHelper = require('@helpers/userHelper') // DTOs const UserTransformDTO = require('@dtos/userDTO') const organizationDTO = require('@dtos/organizationDTO') - +const { eventBodyDTO } = require('@dtos/userDTO') module.exports = class AdminHelper { /** * Delete User @@ -92,6 +94,119 @@ module.exports = class AdminHelper { } } + /** + * Assigns a role to a user within an organization. + * Params come from token. Body has user_id, role_id, optional organization_id. + * + * @param {Object} params + * @param {string} params.tenant_code + * @param {string} params.organization_code + * @param {Object} body + * @param {number|string} body.user_id + * @param {number|string} body.role_id + * @param {number|string} [body.organization_id] + */ + static async assignRole(params, body) { + try { + const { tenant_code, organization_code } = params + const { user_id, role_id, organization_id } = body + + const user = await userQueries.findUserWithOrganization({ id: user_id, tenant_code }) + if (!user) { + return responses.failureResponse({ + message: 'USER_NOT_FOUND', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + + const orgs = Array.isArray(user.organizations) ? user.organizations : [] + const org = orgs.find( + (o) => o?.code === organization_code || String(o?.id) === String(organization_id || '') + ) + + if (!org) { + return responses.failureResponse({ + message: 'USER_ORGANIZATION_NOT_FOUND', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + + const alreadyHasRole = Array.isArray(org.roles) + ? org.roles.some((r) => String(r.id) === String(role_id)) + : false + + if (alreadyHasRole) { + return responses.failureResponse({ + message: 'USER_ROLE_ALREADY_EXISTS', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + + let mapping + try { + mapping = await userOrganizationRoleQueries.create({ + tenant_code, + user_id: user.id, + organization_code: org.code, + role_id, + }) + } catch (error) { + if (error instanceof ForeignKeyConstraintError) { + return responses.failureResponse({ + message: 'INVALID_ROLE_ID', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + if (error instanceof UniqueConstraintError) { + return responses.failureResponse({ + message: 'USER_ROLE_ALREADY_EXISTS', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + throw error + } + + // Post-assignment operations (best-effort) + try { + const updatedUser = await userQueries.findUserWithOrganization({ id: user_id, tenant_code }) + const newValues = utils.extractDelta(user, updatedUser) + + const eventBody = eventBodyDTO({ + entity: 'user', + eventType: 'update', + entityId: user.id, + oldValues: user, + newValues, + args: { created_at: updatedUser.created_at, updated_at: updatedUser.updated_at }, + }) + + broadcastEvent('userEvents', { requestBody: eventBody, isInternal: true }) + + const redisUserKey = `${common.redisUserPrefix}${tenant_code}_${user.id.toString()}` + await utils.redisDel(redisUserKey) + + // End all active sessions for this user in this tenant (handles Redis + ended_at) + await userHelper.removeAllUserSessions([user.id], tenant_code) + } catch (auxError) { + console.error('Error in post-assignment operations (non-critical):', auxError) + } + + return responses.successResponse({ + statusCode: httpStatusCode.ok, + message: 'USER_ROLE_ASSIGNED_SUCCESSFULLY', + result: { mapping }, + }) + } catch (error) { + console.error('Error in assignRole:', error) + throw error + } + } + /** * Creates a new admin user. * diff --git a/src/services/organization-feature.js b/src/services/organization-feature.js index 53da178f..505ecdee 100644 --- a/src/services/organization-feature.js +++ b/src/services/organization-feature.js @@ -8,10 +8,14 @@ const httpStatusCode = require('@generics/http-status') const organizationFeatureQueries = require('@database/queries/organization-feature') const organizationQueries = require('@database/queries/organization') +const featureRoleMappingQueries = require('@database/queries/featureRoleMapping') const responses = require('@helpers/responses') const utilsHelper = require('@generics/utils') const { UniqueConstraintError } = require('sequelize') const common = require('@constants/common') +const { Op } = require('sequelize') +const roleQueries = require('@database/queries/user-role') +const { sequelize } = require('@database/models/index') module.exports = class organizationFeatureHelper { /** @@ -63,6 +67,7 @@ module.exports = class organizationFeatureHelper { */ static async create(bodyData, tokenInformation, isAdmin = false) { + const transaction = await sequelize.transaction() try { // validate that the feature exists in the default organization if (!isAdmin && tokenInformation.organization_code != process.env.DEFAULT_TENANT_ORG_CODE) { @@ -92,13 +97,47 @@ module.exports = class organizationFeatureHelper { bodyData.created_by = tokenInformation.id // Create the new organization feature - const createdOrgFeature = await organizationFeatureQueries.create(bodyData) + const createdOrgFeature = await organizationFeatureQueries.create(bodyData, { transaction }) + + // If roles are provided, create feature_role_mapping entries + if (bodyData.roles && Array.isArray(bodyData.roles) && bodyData.roles.length > 0) { + // Validate that all requested roles exist for this tenant + const validRoles = await roleQueries.findAll({ + tenant_code: tokenInformation.tenant_code, + title: { + [Op.in]: bodyData.roles, + }, + }) + + const validRoleTitles = validRoles.map((role) => role.title) + if (validRoleTitles.length === 0 || validRoleTitles.length !== bodyData?.roles?.length) { + await transaction.rollback() + return responses.failureResponse({ + message: 'ROLE_NOT_FOUND', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + // Prepare bulk insert data for feature_role_mapping + const featureRoleMappingData = validRoleTitles.map((role) => ({ + feature_code: bodyData.feature_code, + role_title: role, + organization_code: tokenInformation.organization_code, + tenant_code: tokenInformation.tenant_code, + created_by: tokenInformation.id, + updated_by: tokenInformation.id, + })) + + await featureRoleMappingQueries.bulkCreate(featureRoleMappingData, { transaction }) + } + await transaction.commit() return responses.successResponse({ statusCode: httpStatusCode.created, message: 'ORG_FEATURE_CREATED_SUCCESSFULLY', result: createdOrgFeature, }) } catch (error) { + if (transaction) await transaction.rollback() if (error.name === common.SEQUELIZE_FOREIGN_KEY_CONSTRAINT_ERROR) { return responses.failureResponse({ message: 'FEATURE_NOT_FOUND', @@ -112,7 +151,11 @@ module.exports = class organizationFeatureHelper { responseCode: 'CLIENT_ERROR', }) } - throw error + return responses.failureResponse({ + message: error.message || error, + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) } } @@ -125,6 +168,7 @@ module.exports = class organizationFeatureHelper { * @returns {JSON} - Org feature update response. */ static async update(feature_code, bodyData, tokenInformation) { + const transaction = await sequelize.transaction() try { // Prepare filter query to identify the organization feature to update let filterQuery = { @@ -137,7 +181,8 @@ module.exports = class organizationFeatureHelper { const [updatedCount, updatedOrgFeature] = await organizationFeatureQueries.updateOrganizationFeature( filterQuery, - bodyData + bodyData, + { transaction } ) // Return error if no record was updated @@ -149,12 +194,51 @@ module.exports = class organizationFeatureHelper { }) } + // If roles are provided, update feature_role_mapping entries + if (bodyData?.roles && Array.isArray(bodyData?.roles)) { + // Validate that all requested roles exist for this tenant + const validRoles = await roleQueries.findAll({ + tenant_code: tokenInformation.tenant_code, + title: { [Op.in]: bodyData.roles }, + }) + if (validRoles.length !== bodyData.roles.length) { + return responses.failureResponse({ + message: 'ROLE_NOT_FOUND', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + + // First, delete existing mappings for this feature and organization + await featureRoleMappingQueries.delete( + { + feature_code: feature_code, + organization_code: tokenInformation.organization_code, + tenant_code: tokenInformation.tenant_code, + }, + { transaction } + ) + + // Then create new mappings if roles are provided + const featureRoleMappingData = bodyData.roles.map((role) => ({ + feature_code: feature_code, + role_title: role, + organization_code: tokenInformation.organization_code, + tenant_code: tokenInformation.tenant_code, + created_by: tokenInformation.id, + updated_by: tokenInformation.id, + })) + + await featureRoleMappingQueries.bulkCreate(featureRoleMappingData, { transaction }) + } + await transaction.commit() return responses.successResponse({ statusCode: httpStatusCode.ok, message: 'ORG_FEATURE_UPDATED_SUCCESSFULLY', result: updatedOrgFeature?.[0], }) } catch (error) { + if (transaction) await transaction.rollback() if (error.name === common.SEQUELIZE_FOREIGN_KEY_CONSTRAINT_ERROR) { return responses.failureResponse({ message: 'FEATURE_NOT_FOUND', @@ -167,14 +251,16 @@ module.exports = class organizationFeatureHelper { } /** - * List organization features. + * List organization features based on user roles. * @method * @name list - * @param {Object} tokenInformation - Token Information + * @param {String} tenantCode - Tenant Code + * @param {String} orgCode - Organization Code + * @param {Array} userRoles - User roles from token * @returns {JSON} - Organization feature list. */ - static async list(tenantCode, orgCode) { + static async list(tenantCode, orgCode, userRoles = []) { try { let filter = { organization_code: orgCode, @@ -197,7 +283,6 @@ module.exports = class organizationFeatureHelper { // Merge features with Map for efficiency const featureMap = new Map(defaultOrgFeatures.map((feature) => [feature.feature_code, feature])) - // Override with current org features if they exist if (currentOrgFeatures?.length) { currentOrgFeatures.forEach((feature) => { @@ -205,7 +290,72 @@ module.exports = class organizationFeatureHelper { }) } - const organizationFeatures = Array.from(featureMap.values()) + let organizationFeatures = Array.from(featureMap.values()) + + // Filter features based on user roles + if (userRoles?.length > 0) { + // Extract role titles from user roles + const roleTitles = userRoles.map((role) => role.title) + // Check if user has admin or org_admin role + const hasAdminAccess = roleTitles.some((role) => + [common.ADMIN_ROLE, common.ORG_ADMIN_ROLE, common.TENANT_ADMIN_ROLE].includes(role) + ) + + // If user is not admin or org_admin, filter based on role mappings + if (!hasAdminAccess) { + // Fetch role mappings for both current org and default org in a single query + const filterQuery = { + tenant_code: tenantCode, + organization_code: { + [Op.in]: [orgCode, process.env.DEFAULT_ORGANISATION_CODE], + }, + role_title: { [Op.in]: roleTitles }, + } + + const roleFeatureMappings = await featureRoleMappingQueries.findAll(filterQuery) + + if (roleFeatureMappings?.length > 0) { + // Separate mappings and build role-feature maps in single pass + const currentOrgRoleFeatureMap = new Map() + const defaultOrgRoleFeatureMap = new Map() + + // Build maps of roles to features for current and default organizations + roleFeatureMappings.forEach((mapping) => { + const targetMap = + mapping.organization_code === orgCode + ? currentOrgRoleFeatureMap + : defaultOrgRoleFeatureMap + if (!targetMap.has(mapping.role_title)) { + targetMap.set(mapping.role_title, new Set()) + } + targetMap.get(mapping.role_title).add(mapping.feature_code) + }) + + // For each role, determine accessible features + // If role has mappings in current org, use those; otherwise use default org mappings + const accessibleFeatureCodes = new Set() + roleTitles.forEach((role) => { + const currentOrgFeatures = currentOrgRoleFeatureMap.get(role) + if (currentOrgFeatures?.size > 0) { + // Current org has explicit mappings for this role - use only those + currentOrgFeatures.forEach((feature) => accessibleFeatureCodes.add(feature)) + } else { + // No current org mappings for this role - fall back to default org + const defaultOrgFeatures = defaultOrgRoleFeatureMap.get(role) + defaultOrgFeatures?.forEach((feature) => accessibleFeatureCodes.add(feature)) + } + }) + + // Filter to only accessible features + organizationFeatures = organizationFeatures.filter((feature) => + accessibleFeatureCodes.has(feature.feature_code) + ) + } else { + // No role mappings found - restrict to empty set for security + organizationFeatures = [] + } + } + } // Sort the organization features based on the display_order in ascending order const sortedFeatures = organizationFeatures.sort((a, b) => a.display_order - b.display_order) diff --git a/src/validators/v1/admin.js b/src/validators/v1/admin.js index f7741b74..62385e17 100644 --- a/src/validators/v1/admin.js +++ b/src/validators/v1/admin.js @@ -180,4 +180,18 @@ module.exports = { executeRawQuery: (req) => { req.checkBody('query').trim().notEmpty().withMessage('query field is empty') }, + + assignRole: (req) => { + req.checkBody('user_id') + .notEmpty() + .withMessage('user_id field is empty') + .isNumeric() + .withMessage('user_id must be a number') + + req.checkBody('role_id') + .notEmpty() + .withMessage('role_id field is empty') + .isNumeric() + .withMessage('role_id must be a number') + }, }