From a37176204dab675f1ba3257c39e6750728b1f2a8 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Thu, 4 Sep 2025 14:54:06 +0530 Subject: [PATCH 01/36] refactor: remove dep Redis configuration and communication files --- src/configs/redis.js | 26 ---------------- src/generics/redis-communication.js | 47 ----------------------------- 2 files changed, 73 deletions(-) delete mode 100644 src/configs/redis.js delete mode 100644 src/generics/redis-communication.js diff --git a/src/configs/redis.js b/src/configs/redis.js deleted file mode 100644 index bb5c357bc..000000000 --- a/src/configs/redis.js +++ /dev/null @@ -1,26 +0,0 @@ -const redis = require('redis') - -const { elevateLog } = require('elevate-logger') -const logger = elevateLog.init() - -module.exports = async function () { - const redisClient = redis.createClient({ url: process.env.REDIS_HOST }) - - try { - await redisClient.connect() - } catch (error) { - logger.error('Error while making connection to redis client: ', { - triggerNotification: true, - err: error, - }) - } - - redisClient.on('error', (err) => { - logger.error('Error while making connection to redis client: ', { - triggerNotification: true, - error: err, - }) - }) - - global.redisClient = redisClient -} diff --git a/src/generics/redis-communication.js b/src/generics/redis-communication.js deleted file mode 100644 index 57413e73b..000000000 --- a/src/generics/redis-communication.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @method - * @name setKey - Sets Key to the redis cache - * @param {String} key key to save - * @param {Object | Number | String} value data to save - * @param {Number} exp key expiration value in seconds - * @returns {Promise} Returns the success response - * @author Aman Gupta - */ -const setKey = async function (key, value, exp) { - value = JSON.stringify(value) - const result = await redisClient.set(key, value, { - // NX: true, // Only set the key if it does not already exist. - EX: exp, - }) - return result -} - -/** - * @method - * @name getKey - Get Key from the redis cache - * @param {String} key key to get corresponding saved data - * @returns {Promise} Returns the saved corresponding object - * @author Aman Gupta - */ -const getKey = async function (key) { - const data = await redisClient.get(key) - return JSON.parse(data) -} - -/** - * @method - * @name deleteKey - delete key from the redis cache - * @param {String} key key to get corresponding saved data - * @returns {Promise} Returns the deleted corresponding object - * @author Rakesh - **/ -const deleteKey = async function (key) { - const data = await redisClient.del(key) - return JSON.parse(data) -} - -module.exports = { - setKey, - getKey, - deleteKey, -} From d68a4585fecfbe73f553e3c6525b71e4408ca765 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Thu, 11 Sep 2025 21:15:31 +0530 Subject: [PATCH 02/36] feat: add cacheHelper module for Redis and internal caching operations --- src/generics/cacheHelper.js | 255 ++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 src/generics/cacheHelper.js diff --git a/src/generics/cacheHelper.js b/src/generics/cacheHelper.js new file mode 100644 index 000000000..75a068e02 --- /dev/null +++ b/src/generics/cacheHelper.js @@ -0,0 +1,255 @@ +// src/generics/cacheHelper.js +/* eslint-disable no-console */ +const { RedisCache, InternalCache } = require('elevate-node-cache') +const md5 = require('md5') +const common = require('@constants/common') + +/** CONFIG */ +const CACHE_CONFIG = (() => { + try { + if (process.env.CACHE_CONFIG) return JSON.parse(process.env.CACHE_CONFIG) + return common.CACHE_CONFIG + } catch { + return common.CACHE_CONFIG + } +})() + +const ENABLE_CACHE = pickBool(CACHE_CONFIG.enableCache, true) +const SHARDS = toInt(CACHE_CONFIG.shards, 32) +const BATCH = toInt(CACHE_CONFIG.scanBatch, 1000) +const SHARD_RETENTION_DAYS = toInt(CACHE_CONFIG.shardRetentionDays, 7) + +/** Helpers */ +function toInt(v, d) { + const n = parseInt(v, 10) + return Number.isFinite(n) ? n : d +} +function pickBool(v, d) { + if (typeof v === 'boolean') return v + if (typeof v === 'string') return ['1', 'true', 'yes'].includes(v.toLowerCase()) + return d +} + +function tenantKey(tenantCode, parts = []) { + return ['tenant', tenantCode, ...parts].join(':') +} +function orgKey(tenantCode, orgId, parts = []) { + return ['tenant', tenantCode, 'org', orgId, ...parts].join(':') +} +function namespaceEnabled(ns) { + if (!ns) return true + const nsCfg = CACHE_CONFIG.namespaces && CACHE_CONFIG.namespaces[ns] + return !(nsCfg && nsCfg.enabled === false) +} + +/** + * TTL resolution for namespace. + * callerTtl (explicit) wins. + * fallback to namespace.defaultTtl. + * fallback to undefined (no expiry). + */ +function nsTtl(ns, callerTtl) { + if (callerTtl != null) return Number(parseInt(callerTtl, 10)) + const nsCfg = CACHE_CONFIG.namespaces && CACHE_CONFIG.namespaces[ns] + const v = nsCfg && nsCfg.defaultTtl + return v != null ? Number(parseInt(v, 10)) : undefined +} + +/** + * Determine whether to use internal (in-memory) cache for this namespace. + * callerUseInternal (explicit param) wins. + * Otherwise check namespace.useInternal, then global CACHE_CONFIG.useInternal, then false. + */ +function nsUseInternal(ns, callerUseInternal) { + if (typeof callerUseInternal === 'boolean') return callerUseInternal + const nsCfg = CACHE_CONFIG.namespaces && CACHE_CONFIG.namespaces[ns] + if (nsCfg && typeof nsCfg.useInternal === 'boolean') return nsCfg.useInternal + if (typeof CACHE_CONFIG.useInternal === 'boolean') return CACHE_CONFIG.useInternal + return false +} + +function namespacedKey({ tenantCode, orgId, ns, id }) { + const base = orgId ? orgKey(tenantCode, orgId, []) : tenantKey(tenantCode, []) + return [base, ns, id].filter(Boolean).join(':') +} +function shardOf(key) { + const h = md5(key) + const asInt = parseInt(h.slice(0, 8), 16) + return (asInt >>> 0) % SHARDS +} + +/** Low-level redis client (best-effort) */ +function getRedisClient() { + try { + if (RedisCache && typeof RedisCache.native === 'function') return RedisCache.native() + } catch (err) { + console.log(err, 'error in getting native redis client') + } +} + +/** Base ops (Internal cache opt-in via config or caller) */ +async function get(key, { useInternal = false } = {}) { + if (!ENABLE_CACHE) return null + // Try Redis first + try { + const val = await RedisCache.getKey(key) + if (val !== null && val !== undefined) return val + } catch (e) { + console.error('redis get error', e) + } + // Only hit InternalCache if explicitly requested + if (useInternal && InternalCache && InternalCache.getKey) { + try { + return InternalCache.getKey(key) + } catch (e) { + /* ignore internal errors */ + } + } + return null +} + +async function set(key, value, ttlSeconds, { useInternal = false } = {}) { + if (!ENABLE_CACHE) return false + let wroteRedis = false + try { + if (ttlSeconds) await RedisCache.setKey(key, value, ttlSeconds) + else await RedisCache.setKey(key, value) + wroteRedis = true + } catch (e) { + console.error('redis set error', e) + } + // Only write to InternalCache if opted in + if (useInternal && InternalCache && InternalCache.setKey) { + try { + InternalCache.setKey(key, value) + } catch (e) {} + } + return wroteRedis +} + +async function del(key, { useInternal = false } = {}) { + try { + await RedisCache.deleteKey(key) + } catch (e) { + console.error('redis del error', e) + } + if (useInternal && InternalCache && InternalCache.delKey) { + try { + InternalCache.delKey(key) + } catch (e) {} + } +} + +/** + * getOrSet + * - key (fallback id) + * - tenantCode + * - ttl (optional): explicit TTL seconds + * - fetchFn: function that returns value + * - orgId, ns, id: for namespaced keys + * - useInternal: optional boolean override. If omitted, resolved from namespace/config. + */ +async function getOrSet({ key, tenantCode, ttl = undefined, fetchFn, orgId, ns, id, useInternal = undefined }) { + if (!namespaceEnabled(ns)) return await fetchFn() + + const resolvedUseInternal = nsUseInternal(ns, useInternal) + console.log('resolvedUseInternal', resolvedUseInternal, ns, useInternal) + const fullKey = + ns || id ? namespacedKey({ tenantCode, orgId, ns: ns || 'ns', id: id || key }) : tenantKey(tenantCode, [key]) + + const cached = await get(fullKey, { useInternal: resolvedUseInternal }) + if (cached !== null && cached !== undefined) return cached + + const value = await fetchFn() + if (value !== undefined) { + await set(fullKey, value, nsTtl(ns, ttl), { useInternal: resolvedUseInternal }) + } + return value +} + +/** Scoped set that uses namespace TTL and namespace useInternal setting */ +async function setScoped({ tenantCode, orgId, ns, id, value, ttl = undefined, useInternal = undefined }) { + if (!namespaceEnabled(ns)) return null + const resolvedUseInternal = nsUseInternal(ns, useInternal) + const fullKey = namespacedKey({ tenantCode, orgId, ns, id }) + await set(fullKey, value, nsTtl(ns, ttl), { useInternal: resolvedUseInternal }) + return fullKey +} + +/** + * Eviction helpers using SCAN by pattern. + * These do not require any tracked sets. Caller should build patterns to match keys to remove. + * + * - scanAndDelete(pattern, opts) + * pattern: glob-style pattern for SCAN (e.g. "tenant:acme:org:123:*") + * opts.batchSize: number of keys to fetch per SCAN iteration (default BATCH) + * opts.unlink: if true will attempt UNLINK when available + */ +async function scanAndDelete(pattern, { batchSize = BATCH, unlink = true } = {}) { + const redis = getRedisClient() + if (!redis) return + let cursor = '0' + do { + // redis.scan(cursor, 'MATCH', pattern, 'COUNT', batchSize) + const res = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', batchSize) + cursor = res && res[0] ? res[0] : '0' + const keys = res && res[1] ? res[1] : [] + if (keys.length) { + // try unlink as best-effort + try { + if (unlink && typeof redis.unlink === 'function') await redis.unlink(...keys) + else await redis.del(...keys) + } catch (e) { + // fallback to individual deletes + for (const k of keys) { + try { + if (unlink && typeof redis.unlink === 'function') await redis.unlink(k) + else await redis.del(k) + } catch (__) {} + } + } + } + } while (cursor !== '0') +} + +/** Evict all keys for a tenant + org by pattern */ +async function evictOrgByPattern(tenantCode, orgId, { patternSuffix = '*' } = {}) { + if (!tenantCode || !orgId) return + const pattern = `tenant:${tenantCode}:org:${orgId}:${patternSuffix}` + await scanAndDelete(pattern) +} + +/** Evict tenant-level keys by pattern */ +async function evictTenantByPattern(tenantCode, { patternSuffix = '*' } = {}) { + if (!tenantCode) return + const pattern = `tenant:${tenantCode}:${patternSuffix}` + await scanAndDelete(pattern) +} + +/** Public API */ +module.exports = { + // Base ops + get, + set, + del, + getOrSet, + tenantKey, + + // Scoped helpers + setScoped, + namespacedKey, + + // Eviction (pattern based) + evictOrgByPattern, + evictTenantByPattern, + scanAndDelete, + + // Introspection + _internal: { + getRedisClient, + SHARDS, + BATCH, + ENABLE_CACHE, + CACHE_CONFIG, + }, +} From 96ca958bd72750128555e9324f8b4adb95c1e966 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Thu, 11 Sep 2025 21:16:47 +0530 Subject: [PATCH 03/36] feat: integrate caching mechanism for user profile retrieval and updates --- src/services/user.js | 214 ++++++++++++++++++++++++------------------- 1 file changed, 119 insertions(+), 95 deletions(-) diff --git a/src/services/user.js b/src/services/user.js index c5663fa2f..5fe5c0575 100644 --- a/src/services/user.js +++ b/src/services/user.js @@ -25,6 +25,8 @@ const { eventBodyDTO, keysFilter } = require('@dtos/userDTO') const { broadcastUserEvent } = require('@helpers/eventBroadcasterMain') +const cacheClient = require('@generics/cacheHelper') + module.exports = class UserHelper { /** * update profile @@ -140,10 +142,16 @@ module.exports = class UserHelper { }, }) } - const redisUserKey = common.redisUserPrefix + tenantCode + '_' + id.toString() - if (await utils.redisGet(redisUserKey)) { - await utils.redisDel(redisUserKey) - } + + const ns = common.CACHE_CONFIG.namespaces.profile.name + const cacheKey = cacheClient.namespacedKey({ + tenantCode, + orgId: orgCode, + ns, + id, + }) + await cacheClient.del(cacheKey) + const processDbResponse = await utils.processDbResponse( JSON.parse(JSON.stringify(updatedData[0])), validationData @@ -295,107 +303,117 @@ module.exports = class UserHelper { * @param {string} searchText - search text. * @returns {JSON} - user information */ - static async read(id, header = null, language, tenantCode) { + static async read(id, header = null, language, tenantCode, organizationCode) { try { - let filter = {} - console.log(tenantCode) - if (utils.isNumeric(id)) { - filter = { id: id, tenant_code: tenantCode } - } else { - filter = { share_link: id } - } - - const redisUserKey = common.redisUserPrefix + tenantCode + '_' + id.toString() - const userDetails = (await utils.redisGet(redisUserKey)) || false - if (!userDetails) { - let options = { - attributes: { - exclude: ['password', 'refresh_tokens'], - }, - } - if (header.internal_access_token) { - options.paranoid = false - } - let user = await userQueries.findUserWithOrganization(filter, options) - - if (!user) { - return responses.failureResponse({ - message: 'USER_NOT_FOUND', - statusCode: httpStatusCode.not_found, - responseCode: 'UNAUTHORIZED', - }) - } - - let roles = user.organizations[0].roles - - if (!roles) { - return responses.failureResponse({ - message: 'ROLE_NOT_FOUND', - statusCode: httpStatusCode.not_acceptable, - responseCode: 'CLIENT_ERROR', - }) - } - if (language && language !== common.ENGLISH_LANGUGE_CODE) { - utils.setRoleLabelsByLanguage(roles, language) - } else { - roles.map((roles) => { - delete roles.translations - return roles - }) - } + const filter = utils.isNumeric(id) ? { id, tenant_code: tenantCode } : { share_link: id } + + const ns = common.CACHE_CONFIG.namespaces.profile.name + const cacheKey = cacheClient.namespacedKey({ + tenantCode, + orgId: organizationCode, + ns, + id, + }) - //user.user_roles = roles + // Try cache first + let userDetails = await cacheClient.get(cacheKey) + if (userDetails) { + if (userDetails.image) userDetails.image = await utils.getDownloadableUrl(userDetails.image) + return responses.successResponse({ + statusCode: httpStatusCode.ok, + message: 'PROFILE_FETCHED_SUCCESSFULLY', + result: userDetails, + }) + } - let defaultOrganizationCode = process.env.DEFAULT_ORGANISATION_CODE + // Cache miss -> load from DB and compute + const options = { + attributes: { exclude: ['password', 'refresh_tokens'] }, + } + if (header && header.internal_access_token) options.paranoid = false - let userOrg = user.organizations[0].code + const user = await userQueries.findUserWithOrganization(filter, options) + if (!user) { + return responses.failureResponse({ + message: 'USER_NOT_FOUND', + statusCode: httpStatusCode.not_found, + responseCode: 'UNAUTHORIZED', + }) + } - let validationData = await entityTypeQueries.findUserEntityTypesAndEntities({ - status: 'ACTIVE', - organization_code: { - [Op.in]: [userOrg, defaultOrganizationCode], - }, - tenant_code: tenantCode, - model_names: { [Op.contains]: [await userQueries.getModelName()] }, + const roles = user.organizations[0].roles + if (!roles) { + return responses.failureResponse({ + message: 'ROLE_NOT_FOUND', + statusCode: httpStatusCode.not_acceptable, + responseCode: 'CLIENT_ERROR', }) - const prunedEntities = removeDefaultOrgEntityTypes(validationData, user.organizations[0].id) - const permissionsByModule = await this.getPermissions(user.organizations[0].roles) - user.permissions = permissionsByModule + } - const processDbResponse = await utils.processDbResponse(user, prunedEntities) + if (language && language !== common.ENGLISH_LANGUAGE_CODE) { + utils.setRoleLabelsByLanguage(roles, language) + } else { + roles.forEach((r) => { + delete r.translations + }) + } - if (processDbResponse) { - ;['email', 'phone'].forEach((field) => { - const value = processDbResponse[field] - if (typeof value === 'string' && value.trim() !== '') { - processDbResponse[field] = emailEncryption.decrypt(value) - } + const defaultOrganizationCode = process.env.DEFAULT_ORGANISATION_CODE + const userOrg = user.organizations[0].code + const modelName = await userQueries.getModelName() + + const prunedEntities = await cacheClient.getOrSet({ + tenantCode, + orgId: organizationCode, + ns: common.CACHE_CONFIG.namespaces.entity_types.name, + id: `${userOrg}:${modelName}`, // per-org stable id + fetchFn: async () => { + const raw = await entityTypeQueries.findUserEntityTypesAndEntities({ + status: 'ACTIVE', + organization_code: { [Op.in]: [userOrg, defaultOrganizationCode] }, + tenant_code: tenantCode, + model_names: { [Op.contains]: [modelName] }, }) - } + return removeDefaultOrgEntityTypes(raw, user.organizations[0].id) + }, + }) - if (utils.validateRoleAccess(roles, [common.MENTOR_ROLE, common.MENTEE_ROLE])) { - await utils.redisSet(redisUserKey, processDbResponse, common.redisUserCacheTTL) - } + const permissionsByModule = await this.getPermissions(user.organizations[0].roles) + user.permissions = permissionsByModule - processDbResponse['image_cloud_path'] = processDbResponse.image - if (processDbResponse && processDbResponse.image) { - processDbResponse.image = await utils.getDownloadableUrl(processDbResponse.image) - } - return responses.successResponse({ - statusCode: httpStatusCode.ok, - message: 'PROFILE_FETCHED_SUCCESSFULLY', - result: processDbResponse ? processDbResponse : {}, + const processDbResponse = await utils.processDbResponse(user, prunedEntities) + + if (processDbResponse) { + ;['email', 'phone'].forEach((field) => { + const value = processDbResponse[field] + if (typeof value === 'string' && value.trim() !== '') { + processDbResponse[field] = emailEncryption.decrypt(value) + } }) - } else { - if (userDetails && userDetails.image) { - userDetails.image = await utils.getDownloadableUrl(userDetails.image) - } - return responses.successResponse({ - statusCode: httpStatusCode.ok, - message: 'PROFILE_FETCHED_SUCCESSFULLY', - result: userDetails ? userDetails : {}, + } + + // Conditional caching: only cache if user has mentor/mentee role access + if (utils.validateRoleAccess(roles, [common.MENTOR_ROLE, common.MENTEE_ROLE])) { + await cacheClient.setScoped({ + tenantCode, + orgId: organizationCode, + ns, + id, + value: processDbResponse, }) } + + // Prepare image url for response + if (processDbResponse && processDbResponse.image) { + processDbResponse.image_cloud_path = processDbResponse.image + processDbResponse.image = await utils.getDownloadableUrl(processDbResponse.image) + } + + return responses.successResponse({ + statusCode: httpStatusCode.ok, + message: 'PROFILE_FETCHED_SUCCESSFULLY', + result: processDbResponse || {}, + }) } catch (error) { console.log(error) throw error @@ -565,10 +583,16 @@ module.exports = class UserHelper { { id: id, tenant_code: tenantCode }, bodyData ) - const redisUserKey = common.redisUserPrefix + tenantCode + '_' + id.toString() - if (await utils.redisGet(redisUserKey)) { - await utils.redisDel(redisUserKey) - } + + const ns = common.CACHE_CONFIG.namespaces.profile.name + const cacheKey = cacheClient.namespacedKey({ + tenantCode, + orgId: orgCode, + ns, + id, + }) + await cacheClient.del(cacheKey) + const processDbResponse = await utils.processDbResponse( JSON.parse(JSON.stringify(updatedData[0])), dataValidation From 25c96a02f38ba3411a2c6564003aa1f98b400e29 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Thu, 11 Sep 2025 21:17:08 +0530 Subject: [PATCH 04/36] feat: enhance user read method to include organization code in user details retrieval --- src/controllers/v1/user.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/controllers/v1/user.js b/src/controllers/v1/user.js index dc6954306..a2f884280 100644 --- a/src/controllers/v1/user.js +++ b/src/controllers/v1/user.js @@ -49,7 +49,8 @@ module.exports = class User { req.params.id ? req.params.id : req.decodedToken.id, req.headers, req.query.language ? req.query.language : '', - req.decodedToken.tenant_code + req.decodedToken.tenant_code, + req.decodedToken.organization_code ) return userDetails } catch (error) { From d7119e5b7313a6286351dcd1ea860c8a79d9e67c Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Thu, 11 Sep 2025 21:17:34 +0530 Subject: [PATCH 05/36] fix: correct spelling of ENGLISH_LANGUAGE_CODE in common constants and update user-role service to use the corrected constant --- src/constants/common.js | 12 +++++++++++- src/services/user-role.js | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/constants/common.js b/src/constants/common.js index 54bc26da9..0335c471a 100644 --- a/src/constants/common.js +++ b/src/constants/common.js @@ -101,7 +101,7 @@ module.exports = { WRITE_ACCESS: 'w', READ_ACCESS: 'r', TYPE_ALL: 'all', - ENGLISH_LANGUGE_CODE: 'en', + ENGLISH_LANGUAGE_CODE: 'en', ORG_CODE_HEADER: 'organizationcode', TENANT_CODE_HEADER: 'tenantcode', DELETE_METHOD: 'DELETE', @@ -113,4 +113,14 @@ module.exports = { SIGNEDUP_STATUS: 'SIGNEDUP', SEQUELIZE_UNIQUE_CONSTRAINT_ERROR: 'SequelizeUniqueConstraintError', SEQUELIZE_UNIQUE_CONSTRAINT_ERROR_CODE: 'ER_DUP_ENTRY', + CACHE_CONFIG: { + enableCache: true, + enableTracking: true, + shards: 32, + namespaces: { + profile: { name: 'profile', enabled: true, defaultTtl: 43200, useInternal: false }, + entity_types: { name: 'entity_types', enabled: true, defaultTtl: 86400, useInternal: false }, + tenant: { name: 'tenant', enabled: true, defaultTtl: 86400, useInternal: false }, + }, + }, } diff --git a/src/services/user-role.js b/src/services/user-role.js index 12032da20..4f0d93133 100644 --- a/src/services/user-role.js +++ b/src/services/user-role.js @@ -200,7 +200,7 @@ module.exports = class userRoleHelper { responseCode: 'CLIENT_ERROR', }) } - if (language && language !== common.ENGLISH_LANGUGE_CODE) { + if (language && language !== common.ENGLISH_LANGUAGE_CODE) { utils.setRoleLabelsByLanguage(roles.rows, language) } else { roles.rows.map((labels) => { From 43b08fb2510dab43c8a3e7c9ef1f5a0985658255 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Thu, 11 Sep 2025 21:17:49 +0530 Subject: [PATCH 06/36] chore: update elevate-node-cache dependency to version 2.0.0 --- src/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/package.json b/src/package.json index 51f05042b..17fcdc120 100644 --- a/src/package.json +++ b/src/package.json @@ -43,7 +43,7 @@ "elevate-cloud-storage": "^2.6.3", "elevate-encryption": "^1.0.1", "elevate-logger": "^3.1.0", - "elevate-node-cache": "^1.0.6", + "elevate-node-cache": "^2.0.0", "elevate-services-health-check": "^0.0.6", "email-validator": "^2.0.4", "express": "^4.17.1", From 53114eeee2233a3f656777e3a6d6114ac65bd7a9 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Thu, 11 Sep 2025 21:18:30 +0530 Subject: [PATCH 07/36] feat: implement caching for tenant domain, tenant details and entity type read in user login process --- src/services/account.js | 176 ++++++++++++++++++++++++++-------------- 1 file changed, 116 insertions(+), 60 deletions(-) diff --git a/src/services/account.js b/src/services/account.js index 724479616..41d586f77 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -39,7 +39,7 @@ const UserTransformDTO = require('@dtos/userDTO') const notificationUtils = require('@utils/notification') const userHelper = require('@helpers/userHelper') const { broadcastUserEvent } = require('@helpers/eventBroadcasterMain') - +const cacheClient = require('@generics/cacheHelper') module.exports = class AccountHelper { /** * create account @@ -636,43 +636,94 @@ module.exports = class AccountHelper { } /** - * login user account - * @method - * @name login - * @param {Object} bodyData -request body contains user login deatils. - * @param {String} bodyData.email - user email. - * @param {String} bodyData.password - user password. - * @param {Object} deviceInformation - device information - * @returns {JSON} - returns susccess or failure of login details. + * Authenticate a user and create a session for the given tenant domain. + * + * Steps (logic unchanged): + * 1. Validate tenant domain via cache then load tenant details. + * 2. Validate and classify identifier (email | phone | username). + * 3. Query user by identifier and tenant, ensure password exists and user is active. + * 4. Enforce active session limit when configured. + * 5. Verify password, create user session, prune admin roles, transform user object. + * 6. Generate access and refresh tokens, fetch pruned entity types for user's org, + * process DB response, attach downloadable image URL if present. + * 7. Update session storage/Redis and return success response. + * + * Logic, return shape and error handling are preserved exactly from the original. + * + * @async + * @function login + * @param {Object} bodyData - Request body for login. + * @param {string} bodyData.identifier - Email, phone or username used to identify the user. Case-insensitive. + * @param {string} bodyData.password - Plain text password to validate. + * @param {string} [bodyData.phone_code] - Optional phone country code used when identifier is a phone. + * @param {Object} deviceInformation - Device metadata to store with the session (e.g. { ip, userAgent, deviceId }). + * @param {string} domain - Tenant domain string used to resolve tenant information. + * @returns {Promise} Resolves to a response object from responses.successResponse or responses.failureResponse. + * + * The success response resolves with: + * { + * statusCode: httpStatusCode.ok, + * message: 'LOGGED_IN_SUCCESSFULLY', + * result: { + * access_token: string, + * refresh_token: string, + * user: Object // transformed user DTO (password removed) with `identifier` set to the original identifier + * } + * } + * + * Failure responses use responses.failureResponse and return early for cases such as: + * - missing identifier + * - tenant domain not found + * - tenant not found + * - identifier/password invalid + * - active session limit exceeded + * + * @throws {Error} Re-throws any unexpected error encountered during processing. */ - static async login(bodyData, deviceInformation, domain) { try { - const notFoundResponse = (message) => + const makeNotFoundResponse = (message) => responses.failureResponse({ message, statusCode: httpStatusCode.not_acceptable, responseCode: 'CLIENT_ERROR', }) - // Validate tenant domain - const tenantDomain = await tenantDomainQueries.findOne({ domain }) + // Cache lookup for tenant domain + const tenantDomain = await cacheClient.getOrSet({ + tenantCode: null, + orgId: null, + ns: common.CACHE_CONFIG.namespaces.tenant.name, + id: domain, + fetchFn: async () => tenantDomainQueries.findOne({ domain }), + }) + if (!tenantDomain) { - return notFoundResponse('TENANT_DOMAIN_NOT_FOUND_PING_ADMIN') + return makeNotFoundResponse('TENANT_DOMAIN_NOT_FOUND_PING_ADMIN') } - // Validate tenant - const tenantDetail = await tenantQueries.findOne({ - code: tenantDomain.tenant_code, + // Cache lookup for tenant by tenant_code + const tenantCode = tenantDomain.tenant_code + const tenantDetail = await cacheClient.getOrSet({ + tenantCode, + orgId: null, + ns: common.CACHE_CONFIG.namespaces.tenant.name, + id: tenantCode, + fetchFn: async () => tenantQueries.findOne({ code: tenantCode }), }) + if (!tenantDetail) { - return notFoundResponse('TENANT_NOT_FOUND_PING_ADMIN') + return makeNotFoundResponse('TENANT_NOT_FOUND_PING_ADMIN') } - // Helper functions to detect identifier type - const isEmail = (str) => /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(str) - const isPhone = (str) => /^\+?[1-9]\d{1,14}$/.test(str) // Adjust regex as needed - const isUsername = (str) => /^[a-zA-Z0-9_]{3,30}$/.test(str) + // Identifier validators + const EMAIL_RE = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/ + const PHONE_RE = /^\+?[1-9]\d{1,14}$/ + const USERNAME_RE = /^[a-zA-Z0-9_]{3,30}$/ + + const isEmailFormat = (s) => EMAIL_RE.test(s) + const isPhoneFormat = (s) => PHONE_RE.test(s) + const isUsernameFormat = (s) => USERNAME_RE.test(s) const identifier = bodyData.identifier?.toLowerCase() if (!identifier) { @@ -683,7 +734,7 @@ module.exports = class AccountHelper { }) } - // Prepare query based on identifier type + // Build user query based on identifier type const query = { [Op.or]: [], password: { [Op.ne]: null }, @@ -691,14 +742,18 @@ module.exports = class AccountHelper { tenant_code: tenantDetail.code, } - if (isEmail(identifier)) { + if (isEmailFormat(identifier)) { query[Op.or].push({ email: emailEncryption.encrypt(identifier) }) - } else if (isPhone(identifier)) { - query[Op.or].push({ phone: emailEncryption.encrypt(identifier), phone_code: bodyData.phone_code }) // Adjust if phone encryption differs + } else if (isPhoneFormat(identifier)) { + query[Op.or].push({ + phone: emailEncryption.encrypt(identifier), + phone_code: bodyData.phone_code, + }) } else { query[Op.or].push({ username: identifier }) } - // Find user + + // Find user with org const userInstance = await userQueries.findUserWithOrganization(query, {}, true) let user = userInstance ? userInstance.toJSON() : null @@ -710,7 +765,7 @@ module.exports = class AccountHelper { }) } - // Check active session limit + // Enforce active session limit if (process.env.ALLOWED_ACTIVE_SESSIONS != null) { const activeSessionCount = await userSessionsService.activeUserSessionCounts(user.id) if (activeSessionCount >= process.env.ALLOWED_ACTIVE_SESSIONS) { @@ -732,7 +787,7 @@ module.exports = class AccountHelper { }) } - // Create user session + // Create session const userSessionDetails = await userSessionsService.createUserSession( user.id, '', @@ -741,16 +796,7 @@ module.exports = class AccountHelper { user.tenant_code ) - // Determine tenant ID - /* let tenantDetails = await organizationQueries.findOne( - { id: user.organization_id }, - { attributes: ['related_orgs'] } - ) - const tenant_id = - tenantDetails && tenantDetails.parent_id !== null ? tenantDetails.parent_id : user.organization_id - */ - - //Remove all 'admin' roles from user.user_organizations + // Remove admin roles from user_organizations if (user?.user_organizations?.length) { user.user_organizations.forEach((org) => { if (org.roles) { @@ -759,16 +805,16 @@ module.exports = class AccountHelper { }) } - // Transform user data + // Transform user for response user = UserTransformDTO.transform(user) - const tokenDetail = { + const tokenPayload = { data: { id: user.id, name: user.name, session_id: userSessionDetails.result.id, - organization_ids: user.organizations.map((org) => String(org.id)), // Convert to string - organization_codes: user.organizations.map((org) => String(org.code)), // Convert to string // tenant_id: tenant_id, + organization_ids: user.organizations.map((org) => String(org.id)), + organization_codes: user.organizations.map((org) => String(org.code)), tenant_code: tenantDetail.code, organizations: user.organizations, }, @@ -776,12 +822,12 @@ module.exports = class AccountHelper { // Generate tokens const accessToken = utilsHelper.generateToken( - tokenDetail, + tokenPayload, process.env.ACCESS_TOKEN_SECRET, common.accessTokenExpiry ) const refreshToken = utilsHelper.generateToken( - tokenDetail, + tokenPayload, process.env.REFRESH_TOKEN_SECRET, common.refreshTokenExpiry ) @@ -790,31 +836,37 @@ module.exports = class AccountHelper { const modelName = await userQueries.getModelName() - const orgCodes = user.organizations?.map((org) => org.code).filter(Boolean) || [] + const orgCodes = user.organizations?.map((o) => o.code).filter(Boolean) || [] orgCodes.push(process.env.DEFAULT_ORGANISATION_CODE) - let validationData = await entityTypeQueries.findUserEntityTypesAndEntities({ - status: 'ACTIVE', - organization_code: { - [Op.in]: orgCodes, + // Fetch pruned entities for the user's primary org and model + const prunedEntities = await cacheClient.getOrSet({ + tenantCode, + orgId: tokenPayload.data.organization_codes[0], + ns: common.CACHE_CONFIG.namespaces.entity_types.name, + id: `${tokenPayload.data.organization_codes[0]}:${modelName}`, + fetchFn: async () => { + const raw = await entityTypeQueries.findUserEntityTypesAndEntities({ + status: 'ACTIVE', + organization_code: { [Op.in]: orgCodes }, + tenant_code: tenantDetail.code, + model_names: { [Op.contains]: [modelName] }, + }) + return removeDefaultOrgEntityTypes(raw, user.organizations[0].id) }, - tenant_code: tenantDetail.code, - model_names: { [Op.contains]: [modelName] }, }) - const prunedEntities = removeDefaultOrgEntityTypes(validationData, user.organizations?.[0]?.id) - user = await utils.processDbResponse(user, prunedEntities) if (user && user.image) { user.image = await utils.getDownloadableUrl(user.image) } - // Return original identifier (email, phone, or username) + // Preserve original identifier for response user.identifier = identifier const result = { access_token: accessToken, refresh_token: refreshToken, user } - // Update session and Redis + // Update session storage / Redis await userSessionsService.updateUserSessionAndsetRedisData( userSessionDetails.result.id, accessToken, @@ -1989,10 +2041,14 @@ module.exports = class AccountHelper { await userQueries.updateUser({ id: user.id, tenant_code: tenantCode }, updateParams) //await UserCredentialQueries.updateUser({ email: userCredentials.email }, { password: bodyData.newPassword }) - const redisUserKey = common.redisUserPrefix + tenantCode + '_' + user.id.toString() - - // remove profile caching - await utils.redisDel(redisUserKey) + const ns = common.CACHE_CONFIG.namespaces.profile.name + const cacheKey = cacheClient.namespacedKey({ + tenantCode, + orgId: orgCode, + ns, + id, + }) + await cacheClient.del(cacheKey) // remove reset otp caching await utils.redisDel(user?.username) From 406a70e382906ac1fe1fdcbd2cd64a13b281b9f0 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 15 Sep 2025 17:59:17 +0530 Subject: [PATCH 08/36] feat: implement versioning support in cache helper with TTL management and eviction methods --- src/generics/cacheHelper.js | 170 +++++++++++++++++++++++++++++++++++- 1 file changed, 166 insertions(+), 4 deletions(-) diff --git a/src/generics/cacheHelper.js b/src/generics/cacheHelper.js index 75a068e02..0463c2df7 100644 --- a/src/generics/cacheHelper.js +++ b/src/generics/cacheHelper.js @@ -19,6 +19,10 @@ const SHARDS = toInt(CACHE_CONFIG.shards, 32) const BATCH = toInt(CACHE_CONFIG.scanBatch, 1000) const SHARD_RETENTION_DAYS = toInt(CACHE_CONFIG.shardRetentionDays, 7) +// Version config +const VERSION_CACHE_TTL = toInt(CACHE_CONFIG.versionCacheTtlSeconds, 5) // seconds, short in-process TTL +const VERSION_DEFAULT = toInt(CACHE_CONFIG.versionDefault || 0, 0) + /** Helpers */ function toInt(v, d) { const n = parseInt(v, 10) @@ -72,12 +76,19 @@ function namespacedKey({ tenantCode, orgId, ns, id }) { const base = orgId ? orgKey(tenantCode, orgId, []) : tenantKey(tenantCode, []) return [base, ns, id].filter(Boolean).join(':') } + +async function versionedKey({ tenantCode, orgId, ns, id, key }) { + return buildVersionedKey({ tenantCode, orgId, ns, id, key }) +} function shardOf(key) { const h = md5(key) const asInt = parseInt(h.slice(0, 8), 16) return (asInt >>> 0) % SHARDS } +/** In-process short cache for version lookups */ +const _versionCache = new Map() // key -> { ver: number, expiresAt: timestamp } + /** Low-level redis client (best-effort) */ function getRedisClient() { try { @@ -87,6 +98,98 @@ function getRedisClient() { } } +/** Version key name resolution */ +function versionKeyName({ tenantCode, orgId, ns }) { + if (tenantCode && orgId && ns) return `__version:tenant:${tenantCode}:org:${orgId}:ns:${ns}` + if (tenantCode && ns) return `__version:tenant:${tenantCode}:ns:${ns}` + if (tenantCode) return `__version:tenant:${tenantCode}` + return `__version:global` +} + +/** Get version from in-process cache or Redis (short TTL). */ +async function getVersion({ tenantCode, orgId, ns } = {}) { + const vKey = versionKeyName({ tenantCode, orgId, ns }) + const cached = _versionCache.get(vKey) + if (cached && cached.expiresAt > Date.now()) return cached.ver + + try { + // try Redis via wrapper + if (RedisCache && typeof RedisCache.getKey === 'function') { + const raw = await RedisCache.getKey(vKey) + const ver = raw ? parseInt(raw, 10) || VERSION_DEFAULT : VERSION_DEFAULT + _versionCache.set(vKey, { ver, expiresAt: Date.now() + VERSION_CACHE_TTL * 1000 }) + return ver + } + } catch (e) { + console.error('getVersion redis read error', e) + } + + // fallback default + _versionCache.set(vKey, { ver: VERSION_DEFAULT, expiresAt: Date.now() + VERSION_CACHE_TTL * 1000 }) + return VERSION_DEFAULT +} + +/** + * Bump version atomically for the given level. + * Preferred method is native Redis INCR. Fallback to read+set. + * Returns new version number. + */ +async function bumpVersion({ tenantCode, orgId, ns } = {}) { + const vKey = versionKeyName({ tenantCode, orgId, ns }) + const redis = getRedisClient() + try { + if (redis && typeof redis.incr === 'function') { + // atomic increment + const newVer = await redis.incr(vKey) + // ensure wrapper reflects new value (best-effort) + try { + if (RedisCache && typeof RedisCache.setKey === 'function') { + await RedisCache.setKey(vKey, String(newVer)) + } + } catch (_) {} + _versionCache.set(vKey, { ver: Number(newVer), expiresAt: Date.now() + VERSION_CACHE_TTL * 1000 }) + return Number(newVer) + } + // fallback: read + increment + set + const currRaw = await RedisCache.getKey(vKey) + const curr = currRaw ? parseInt(currRaw, 10) || VERSION_DEFAULT : VERSION_DEFAULT + const newVer = curr + 1 + await RedisCache.setKey(vKey, String(newVer)) + _versionCache.set(vKey, { ver: newVer, expiresAt: Date.now() + VERSION_CACHE_TTL * 1000 }) + return newVer + } catch (e) { + console.error('bumpVersion error', e) + // as last resort update in-memory and return incremented value + const currCached = _versionCache.get(vKey) + const curr = currCached ? currCached.ver : VERSION_DEFAULT + const newVer = curr + 1 + _versionCache.set(vKey, { ver: newVer, expiresAt: Date.now() + VERSION_CACHE_TTL * 1000 }) + try { + await RedisCache.setKey(vKey, String(newVer)) + } catch (_) {} + return newVer + } +} + +/** Build final key with version token inserted so patterns still match. */ +async function buildVersionedKey({ tenantCode, orgId, ns, id, key }) { + // If caller provided ns or id, treat as namespaced. Matches previous behaviour: + // previous code used ns || id ? namespacedKey({ ns: ns || 'ns', id: id||key }) : tenantKey(tenantCode, [key]) + const isNamespaced = Boolean(ns || id) + if (isNamespaced) { + const effNs = ns || 'ns' + const ver = await getVersion({ tenantCode, orgId, ns: effNs }) + const base = orgId ? orgKey(tenantCode, orgId, []) : tenantKey(tenantCode, []) + const final = [base, effNs, `v${ver}`, id || key].filter(Boolean).join(':') + return final + } + // tenant-level key + const ver = await getVersion({ tenantCode }) + const base = tenantKey(tenantCode, []) + const final = [base, `v${ver}`, key].filter(Boolean).join(':') + return final +} + /** Base ops (Internal cache opt-in via config or caller) */ async function get(key, { useInternal = false } = {}) { if (!ENABLE_CACHE) return null @@ -148,14 +251,18 @@ async function del(key, { useInternal = false } = {}) { * - fetchFn: function that returns value * - orgId, ns, id: for namespaced keys * - useInternal: optional boolean override. If omitted, resolved from namespace/config. + * + * NOTE: This function now resolves a versioned key internally. */ async function getOrSet({ key, tenantCode, ttl = undefined, fetchFn, orgId, ns, id, useInternal = undefined }) { if (!namespaceEnabled(ns)) return await fetchFn() const resolvedUseInternal = nsUseInternal(ns, useInternal) - console.log('resolvedUseInternal', resolvedUseInternal, ns, useInternal) + // build versioned key (keeps previous behaviour but adds version token) const fullKey = - ns || id ? namespacedKey({ tenantCode, orgId, ns: ns || 'ns', id: id || key }) : tenantKey(tenantCode, [key]) + ns || id + ? await buildVersionedKey({ tenantCode, orgId, ns: ns || 'ns', id: id || key }) + : await buildVersionedKey({ tenantCode, key }) const cached = await get(fullKey, { useInternal: resolvedUseInternal }) if (cached !== null && cached !== undefined) return cached @@ -167,15 +274,44 @@ async function getOrSet({ key, tenantCode, ttl = undefined, fetchFn, orgId, ns, return value } -/** Scoped set that uses namespace TTL and namespace useInternal setting */ +/** Scoped set that uses namespace TTL and namespace useInternal setting + * Returns the versioned key that was written. + */ async function setScoped({ tenantCode, orgId, ns, id, value, ttl = undefined, useInternal = undefined }) { if (!namespaceEnabled(ns)) return null const resolvedUseInternal = nsUseInternal(ns, useInternal) - const fullKey = namespacedKey({ tenantCode, orgId, ns, id }) + const fullKey = await buildVersionedKey({ tenantCode, orgId, ns, id }) await set(fullKey, value, nsTtl(ns, ttl), { useInternal: resolvedUseInternal }) return fullKey } +/** Scoped delete that uses namespace config (TTL/useInternal) + * Returns the versioned key that was deleted. + */ +async function delScoped({ tenantCode, orgId, ns, id, useInternal = undefined }) { + if (!namespaceEnabled(ns)) return null + const resolvedUseInternal = nsUseInternal(ns, useInternal) + const fullKey = await buildVersionedKey({ tenantCode, orgId, ns, id }) + await del(fullKey, { useInternal: resolvedUseInternal }) + return fullKey +} + +/** + * Evict all keys for a namespace. + * If orgId is provided will target org-level keys, otherwise tenant-level keys. + * patternSuffix defaults to '*' (delete all keys under the namespace). + * + * NOTE: Because version is a token between ns and id, the glob pattern `tenant:acme:users:*` + * will match versioned keys like `tenant:acme:users:v3:...`. + */ +async function evictNamespace({ tenantCode, orgId = null, ns, patternSuffix = '*' } = {}) { + if (!tenantCode || !ns) return + if (!namespaceEnabled(ns)) return + const base = orgId ? `tenant:${tenantCode}:org:${orgId}` : `tenant:${tenantCode}` + const pattern = `${base}:${ns}:${patternSuffix}` + await scanAndDelete(pattern) +} + /** * Eviction helpers using SCAN by pattern. * These do not require any tracked sets. Caller should build patterns to match keys to remove. @@ -226,6 +362,20 @@ async function evictTenantByPattern(tenantCode, { patternSuffix = '*' } = {}) { await scanAndDelete(pattern) } +/** Convenience invalidation by bumping version (fast) */ +async function invalidateNamespaceVersion({ tenantCode, orgId = null, ns } = {}) { + if (!tenantCode || !ns) return + return bumpVersion({ tenantCode, orgId, ns }) +} +async function invalidateTenantVersion({ tenantCode } = {}) { + if (!tenantCode) return + return bumpVersion({ tenantCode }) +} +async function invalidateOrgNamespaceVersion({ tenantCode, orgId, ns } = {}) { + if (!tenantCode || !orgId || !ns) return + return bumpVersion({ tenantCode, orgId, ns }) +} + /** Public API */ module.exports = { // Base ops @@ -238,12 +388,22 @@ module.exports = { // Scoped helpers setScoped, namespacedKey, + versionedKey, // Eviction (pattern based) + delScoped, + evictNamespace, evictOrgByPattern, evictTenantByPattern, scanAndDelete, + // Versioning API + getVersion, + bumpVersion, + invalidateNamespaceVersion, + invalidateTenantVersion, + invalidateOrgNamespaceVersion, + // Introspection _internal: { getRedisClient, @@ -251,5 +411,7 @@ module.exports = { BATCH, ENABLE_CACHE, CACHE_CONFIG, + VERSION_CACHE_TTL, + VERSION_DEFAULT, }, } From a1eb5d52724d2fb6228b964cb0ddfbc0c3ad0a26 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 15 Sep 2025 17:59:42 +0530 Subject: [PATCH 09/36] feat: enhance entity type management with cache invalidation on create, update, and delete operations --- src/services/entityType.js | 79 +++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/src/services/entityType.js b/src/services/entityType.js index 955685af5..115efff18 100644 --- a/src/services/entityType.js +++ b/src/services/entityType.js @@ -1,21 +1,43 @@ -// DependenciesI +// Dependencies const httpStatusCode = require('@generics/http-status') const common = require('@constants/common') const entityTypeQueries = require('@database/queries/entityType') const { UniqueConstraintError } = require('sequelize') -const organizationQueries = require('@database/queries/organization') const { Op } = require('sequelize') const { removeDefaultOrgEntityTypes } = require('@generics/utils') const responses = require('@helpers/responses') +const cacheClient = require('@generics/cacheHelper') + module.exports = class EntityHelper { - /** - * Create entity type. - * @method - * @name create - * @param {Object} bodyData - entity type body data. - * @param {String} id - id. - * @returns {JSON} - Created entity type response. - */ + static async _invalidateEntityTypeCaches({ tenantCode, organizationCode }) { + try { + await cacheClient.invalidateOrgNamespaceVersion({ + tenantCode, + orgId: organizationCode, + ns: common.CACHE_CONFIG.namespaces.entity_types.name, + }) + + await cacheClient.invalidateOrgNamespaceVersion({ + tenantCode, + orgId: organizationCode, + ns: common.CACHE_CONFIG.namespaces.profile.name, + }) + + if (process.env.DEFAULT_ORGANISATION_CODE === organizationCode) { + await cacheClient.invalidateNamespaceVersion({ + tenantCode, + ns: common.CACHE_CONFIG.namespaces.entity_types.name, + }) + await cacheClient.invalidateNamespaceVersion({ + tenantCode, + ns: common.CACHE_CONFIG.namespaces.profile.name, + }) + } + } catch (err) { + console.error('Entity type cache invalidation failed', err) + // Do not throw. Caching failure should not block main operation. + } + } static async create(bodyData, id, organizationCode, organizationId, tenantCode) { bodyData.created_by = id @@ -25,6 +47,10 @@ module.exports = class EntityHelper { bodyData.tenant_code = tenantCode try { const entityType = await entityTypeQueries.createEntityType(bodyData) + + // invalidate caches after successful create + await this._invalidateEntityTypeCaches({ tenantCode, organizationCode }) + return responses.successResponse({ statusCode: httpStatusCode.created, message: 'ENTITY_TYPE_CREATED_SUCCESSFULLY', @@ -42,16 +68,6 @@ module.exports = class EntityHelper { } } - /** - * Update entity type. - * @method - * @name update - * @param {Object} bodyData - body data. - * @param {String} id - entity type id. - * @param {String} loggedInUserId - logged in user id. - * @returns {JSON} - Updated Entity Type. - */ - static async update(bodyData, id, loggedInUserId, organizationCode, tenantCode) { ;(bodyData.updated_by = loggedInUserId), (bodyData.organization_code = organizationCode) try { @@ -74,6 +90,9 @@ module.exports = class EntityHelper { }) } + // invalidate caches after successful update + await this._invalidateEntityTypeCaches({ tenantCode, organizationCode }) + return responses.successResponse({ statusCode: httpStatusCode.accepted, message: 'ENTITY_TYPE_UPDATED_SUCCESSFULLY', @@ -93,6 +112,8 @@ module.exports = class EntityHelper { static async readAllSystemEntityTypes(organizationCode, tenantCode, organizationId) { try { + await this._invalidateEntityTypeCaches({ tenantCode, organizationCode }) + const attributes = ['value', 'label', 'id', 'organization_code'] const entities = await entityTypeQueries.findAllEntityTypes( @@ -123,11 +144,10 @@ module.exports = class EntityHelper { static async readUserEntityTypes(body, organizationCode, tenantCode, organizationId = '') { try { - // Include tenant_code in filter for consistency with schema const filter = { value: body.value, status: 'ACTIVE', - tenant_code: tenantCode, // Ensure tenant isolation + tenant_code: tenantCode, organization_code: { [Op.in]: [process.env.DEFAULT_ORGANISATION_CODE, organizationCode], }, @@ -135,7 +155,6 @@ module.exports = class EntityHelper { const entities = await entityTypeQueries.findUserEntityTypesAndEntities(filter) - // Deduplicate entity types by value, prioritizing orgId const prunedEntities = removeDefaultOrgEntityTypes(entities, organizationId) return responses.successResponse({ @@ -148,15 +167,8 @@ module.exports = class EntityHelper { throw error } } - /** - * Delete entity type. - * @method - * @name delete - * @param {String} id - Delete entity type. - * @returns {JSON} - Entity deleted response. - */ - - static async delete(id, organizationCode) { + + static async delete(id, organizationCode, tenantCode) { try { const deleteCount = await entityTypeQueries.deleteOneEntityType(id, organizationCode) if (deleteCount === 0) { @@ -167,6 +179,9 @@ module.exports = class EntityHelper { }) } + // invalidate caches after successful delete + await this._invalidateEntityTypeCaches({ tenantCode, organizationCode }) + return responses.successResponse({ statusCode: httpStatusCode.accepted, message: 'ENTITY_TYPE_DELETED_SUCCESSFULLY', From 072a8701099ec4425d76213858be863b6f9dd07e Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 15 Sep 2025 18:14:30 +0530 Subject: [PATCH 10/36] feat: add cache invalidation for entity operations on create, update, and delete --- src/services/entities.js | 43 +++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src/services/entities.js b/src/services/entities.js index f517d4af1..17c41cdb7 100644 --- a/src/services/entities.js +++ b/src/services/entities.js @@ -1,11 +1,38 @@ // Dependencies const httpStatusCode = require('@generics/http-status') const entityQueries = require('@database/queries/entities') -const entityTypeQueries = require('@database/queries/entityType') const { UniqueConstraintError, ForeignKeyConstraintError } = require('sequelize') const responses = require('@helpers/responses') +const common = require('@constants/common') +const cacheClient = require('@generics/cacheHelper') module.exports = class EntityHelper { + static async _invalidateEntityCaches({ tenantCode, organizationCode }) { + try { + await cacheClient.invalidateOrgNamespaceVersion({ + tenantCode, + orgId: organizationCode, + ns: common.CACHE_CONFIG.namespaces.entity_types.name, + }) + + await cacheClient.invalidateOrgNamespaceVersion({ + tenantCode, + orgId: organizationCode, + ns: common.CACHE_CONFIG.namespaces.profile.name, + }) + + if (process.env.DEFAULT_ORGANISATION_CODE === organizationCode) { + await cacheClient.invalidateNamespaceVersion({ + tenantCode, + ns: common.CACHE_CONFIG.namespaces.entity_types.name, + }) + } + } catch (err) { + console.error('Entity cache invalidation failed', err) + // Do not throw. Cache failures should not block DB ops. + } + } + /** * Create entity. * @method @@ -14,7 +41,6 @@ module.exports = class EntityHelper { * @param {String} id - id. * @returns {JSON} - Entity created response. */ - static async create(bodyData, userId, tenantCode, organizationCode) { bodyData.created_by = userId bodyData.updated_by = userId @@ -24,6 +50,9 @@ module.exports = class EntityHelper { try { const entity = await entityQueries.createEntity(bodyData) + // invalidate caches after successful create + await this._invalidateEntityCaches({ tenantCode, organizationCode }) + return responses.successResponse({ statusCode: httpStatusCode.created, message: 'ENTITY_CREATED_SUCCESSFULLY', @@ -59,7 +88,6 @@ module.exports = class EntityHelper { * @param {String} loggedInUserId - logged in user id. * @returns {JSON} - Entity updated response. */ - static async update(bodyData, id, loggedInUserId, organizationCode, tenantCode) { bodyData.updated_by = loggedInUserId try { @@ -81,6 +109,10 @@ module.exports = class EntityHelper { responseCode: 'CLIENT_ERROR', }) } + + // invalidate caches after successful update + await this._invalidateEntityCaches({ tenantCode, organizationCode }) + return responses.successResponse({ statusCode: httpStatusCode.accepted, message: 'ENTITY_UPDATED_SUCCESSFULLY', @@ -105,7 +137,6 @@ module.exports = class EntityHelper { * @param {Object} bodyData - entity body data. * @returns {JSON} - Entity read response. */ - static async read(query, tenantCode) { try { let filter @@ -149,7 +180,6 @@ module.exports = class EntityHelper { * @param {String} _id - Delete entity. * @returns {JSON} - Entity deleted response. */ - static async delete(id, organizationCode, tenantCode) { try { const deleteCount = await entityQueries.deleteOneEntity(id, organizationCode, tenantCode) @@ -162,6 +192,9 @@ module.exports = class EntityHelper { }) } + // invalidate caches after successful delete + await this._invalidateEntityCaches({ tenantCode, organizationCode }) + return responses.successResponse({ statusCode: httpStatusCode.accepted, message: 'ENTITY_DELETED_SUCCESSFULLY', From 83f3397f42e64979ee6f2110ddc6e991b9b0ee0a Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 15 Sep 2025 18:15:17 +0530 Subject: [PATCH 11/36] feat: implement caching for read operations of system and user entity types --- src/services/entityType.js | 73 ++++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/src/services/entityType.js b/src/services/entityType.js index 115efff18..9e3d6ed2c 100644 --- a/src/services/entityType.js +++ b/src/services/entityType.js @@ -1,3 +1,4 @@ +// EntityHelper.js // Dependencies const httpStatusCode = require('@generics/http-status') const common = require('@constants/common') @@ -110,28 +111,41 @@ module.exports = class EntityHelper { } } + // Read all system entity types (cached) static async readAllSystemEntityTypes(organizationCode, tenantCode, organizationId) { try { - await this._invalidateEntityTypeCaches({ tenantCode, organizationCode }) - - const attributes = ['value', 'label', 'id', 'organization_code'] + const ns = common.CACHE_CONFIG.namespaces.entity_types.name + const cacheId = `all` // stable id under namespace; versioning handles invalidation + const fetchFn = async () => { + const attributes = ['value', 'label', 'id', 'organization_code'] + const entities = await entityTypeQueries.findAllEntityTypes( + [organizationCode, process.env.DEFAULT_ORGANISATION_CODE], + attributes, + { + tenant_code: tenantCode, + } + ) + const pruned = removeDefaultOrgEntityTypes(entities, organizationId) + return pruned + } - const entities = await entityTypeQueries.findAllEntityTypes( - [organizationCode, process.env.DEFAULT_ORGANISATION_CODE], - attributes, - { - tenant_code: tenantCode, - } - ) - const prunedEntities = removeDefaultOrgEntityTypes(entities, organizationId) + const prunedEntities = await cacheClient.getOrSet({ + key: cacheId, + tenantCode, + orgId: organizationCode, + ns, + id: cacheId, + fetchFn, + }) - if (!prunedEntities.length) { + if (!prunedEntities || !prunedEntities.length) { return responses.failureResponse({ message: 'ENTITY_TYPE_NOT_FOUND', statusCode: httpStatusCode.bad_request, responseCode: 'CLIENT_ERROR', }) } + return responses.successResponse({ statusCode: httpStatusCode.ok, message: 'ENTITY_TYPE_FETCHED_SUCCESSFULLY', @@ -142,25 +156,38 @@ module.exports = class EntityHelper { } } + // Read user entity types by value (cached) static async readUserEntityTypes(body, organizationCode, tenantCode, organizationId = '') { try { - const filter = { - value: body.value, - status: 'ACTIVE', - tenant_code: tenantCode, - organization_code: { - [Op.in]: [process.env.DEFAULT_ORGANISATION_CODE, organizationCode], - }, + const ns = common.CACHE_CONFIG.namespaces.entity_types.name + const cacheId = `user:value:${body.value}` + const fetchFn = async () => { + const filter = { + value: body.value, + status: 'ACTIVE', + tenant_code: tenantCode, + organization_code: { + [Op.in]: [process.env.DEFAULT_ORGANISATION_CODE, organizationCode], + }, + } + const entities = await entityTypeQueries.findUserEntityTypesAndEntities(filter) + const pruned = removeDefaultOrgEntityTypes(entities, organizationId) + return { entity_types: pruned } } - const entities = await entityTypeQueries.findUserEntityTypesAndEntities(filter) - - const prunedEntities = removeDefaultOrgEntityTypes(entities, organizationId) + const result = await cacheClient.getOrSet({ + key: cacheId, + tenantCode, + orgId: organizationCode, + ns, + id: cacheId, + fetchFn, + }) return responses.successResponse({ statusCode: httpStatusCode.ok, message: 'ENTITY_TYPE_FETCHED_SUCCESSFULLY', - result: { entity_types: prunedEntities }, + result, }) } catch (error) { console.error('Error in readUserEntityTypes:', error) From 6d867ac3664087b1e3ba9faffdc7ba79a4c790fe Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 15 Sep 2025 18:15:34 +0530 Subject: [PATCH 12/36] feat: add default versioning and new namespaces for branding and organization in constants --- src/constants/common.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/constants/common.js b/src/constants/common.js index 0335c471a..77abf15d5 100644 --- a/src/constants/common.js +++ b/src/constants/common.js @@ -117,10 +117,13 @@ module.exports = { enableCache: true, enableTracking: true, shards: 32, + versionDefault: 0, namespaces: { profile: { name: 'profile', enabled: true, defaultTtl: 43200, useInternal: false }, entity_types: { name: 'entity_types', enabled: true, defaultTtl: 86400, useInternal: false }, tenant: { name: 'tenant', enabled: true, defaultTtl: 86400, useInternal: false }, + branding: { name: 'branding', enabled: true, defaultTtl: 86400, useInternal: false }, + organization: { name: 'organization', enabled: true, defaultTtl: 86400, useInternal: false }, }, }, } From e6739a320f98c20fdad743c3d7f5764905e87c16 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 15 Sep 2025 18:19:52 +0530 Subject: [PATCH 13/36] feat: update caching mechanism to use versioned keys for user profile retrieval --- src/services/user.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/user.js b/src/services/user.js index 5fe5c0575..dc76fbe1f 100644 --- a/src/services/user.js +++ b/src/services/user.js @@ -308,15 +308,15 @@ module.exports = class UserHelper { const filter = utils.isNumeric(id) ? { id, tenant_code: tenantCode } : { share_link: id } const ns = common.CACHE_CONFIG.namespaces.profile.name - const cacheKey = cacheClient.namespacedKey({ + const fullKey = await cacheClient.versionedKey({ tenantCode, orgId: organizationCode, ns, id, }) - // Try cache first - let userDetails = await cacheClient.get(cacheKey) + const userDetails = await cacheClient.get(fullKey) + if (userDetails) { if (userDetails.image) userDetails.image = await utils.getDownloadableUrl(userDetails.image) return responses.successResponse({ From b6b80aae06f4aa0a6be6653b34ff4e4018d23f50 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 15 Sep 2025 18:29:08 +0530 Subject: [PATCH 14/36] feat: implement cache clearing for deleted user profiles --- src/helpers/userHelper.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/helpers/userHelper.js b/src/helpers/userHelper.js index d3c160988..2123eb8a6 100644 --- a/src/helpers/userHelper.js +++ b/src/helpers/userHelper.js @@ -1,11 +1,11 @@ const common = require('@constants/common') +const cacheClient = require('@generics/cacheHelper') const utils = require('@generics/utils') const userQueries = require('@database/queries/users') const userOrganizationQueries = require('@database/queries/userOrganization') const userOrganizationRoleQueries = require('@database/queries/userOrganizationRole') 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') @@ -77,7 +77,6 @@ const userHelper = { { user_id: user.id, tenant_code: user.tenant_code }, { transaction } ) - await utils.redisDel([`${REDIS_USER_PREFIX}${user.tenant_code}_${userId}`]) const userSessionData = await userSessionsService.findUserSession( { @@ -91,6 +90,20 @@ const userHelper = { ) const userSessionIds = userSessionData.map(({ id }) => id) await userSessionsService.removeUserSessions(userSessionIds) + + // Clear cache entries for this user + try { + const ns = common.CACHE_CONFIG.namespaces.profile.name + const fullKey = await cacheClient.versionedKey({ + tenantCode: user.tenant_code, + orgId: user.organization_code, + ns, + id: userId, + }) + await cacheClient.del(fullKey) + } catch (err) { + console.error('Failed to delete user cache', err) + } }, transactionOptions ) From 18465cf98fdc501ffd597c3eaeb8fe431b9fb317 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 16 Sep 2025 12:15:31 +0530 Subject: [PATCH 15/36] fix: update cache key organization ID reference in account and user services --- src/services/account.js | 2 +- src/services/user.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/account.js b/src/services/account.js index 41d586f77..c32626866 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -2044,7 +2044,7 @@ module.exports = class AccountHelper { const ns = common.CACHE_CONFIG.namespaces.profile.name const cacheKey = cacheClient.namespacedKey({ tenantCode, - orgId: orgCode, + orgId: organizationCode, ns, id, }) diff --git a/src/services/user.js b/src/services/user.js index dc76fbe1f..475f0a859 100644 --- a/src/services/user.js +++ b/src/services/user.js @@ -587,7 +587,7 @@ module.exports = class UserHelper { const ns = common.CACHE_CONFIG.namespaces.profile.name const cacheKey = cacheClient.namespacedKey({ tenantCode, - orgId: orgCode, + orgId: organizationCode, ns, id, }) From b1f600214314353ec09f4b8c3a4968cf8f22a9df Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 16 Sep 2025 12:33:56 +0530 Subject: [PATCH 16/36] fix: user id reference in change password --- src/services/account.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/account.js b/src/services/account.js index c32626866..631fd79f4 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -2046,7 +2046,7 @@ module.exports = class AccountHelper { tenantCode, orgId: organizationCode, ns, - id, + id: userId, }) await cacheClient.del(cacheKey) From e23df979cafebcaf6a6e9846beea3b6e34a2040f Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 22 Sep 2025 20:46:24 +0530 Subject: [PATCH 17/36] feat: enhance organization service to enforce tenant code in related org operations --- src/controllers/v1/organization.js | 13 +- src/database/queries/organization.js | 48 +++---- src/services/organization.js | 182 +++++++++++++++++++++------ 3 files changed, 181 insertions(+), 62 deletions(-) diff --git a/src/controllers/v1/organization.js b/src/controllers/v1/organization.js index e56eb6ea5..24e1dcbcf 100644 --- a/src/controllers/v1/organization.js +++ b/src/controllers/v1/organization.js @@ -92,7 +92,12 @@ module.exports = class Organization { }) } - const updatedOrg = await orgService.update(req.params.id, req.body, req.decodedToken.id) + const updatedOrg = await orgService.update( + req.params.id, + req.body, + req.decodedToken.id, + req.decodedToken.tenant_code + ) return updatedOrg } catch (error) { return error @@ -188,7 +193,8 @@ module.exports = class Organization { try { const result = await orgService.addRelatedOrg( req.params.id ? req.params.id : '', - req.body.related_orgs ? req.body.related_orgs : [] + req.body.related_orgs ? req.body.related_orgs : [], + req.decodedToken.tenant_code ) return result } catch (error) { @@ -199,7 +205,8 @@ module.exports = class Organization { try { const result = await orgService.removeRelatedOrg( req.params.id ? req.params.id : '', - req.body.related_orgs ? req.body.related_orgs : [] + req.body.related_orgs ? req.body.related_orgs : [], + req.decodedToken.tenant_code ) return result } catch (error) { diff --git a/src/database/queries/organization.js b/src/database/queries/organization.js index 0e6d4d9ee..6dd5837a5 100644 --- a/src/database/queries/organization.js +++ b/src/database/queries/organization.js @@ -111,35 +111,36 @@ exports.update = async (filter, update, options = {}) => { } } -exports.appendRelatedOrg = async (relatedOrg, ids, options = {}) => { +exports.appendRelatedOrg = async (relatedOrg, ids, tenantCode, options = {}) => { try { + const whereClause = { + id: ids, + tenant_code: tenantCode, // enforce tenant + [Op.or]: [ + { + [Op.not]: { + related_orgs: { + [Op.contains]: [relatedOrg], + }, + }, + }, + { + related_orgs: { + [Op.is]: null, + }, + }, + ], + } const result = await Organization.update( { related_orgs: sequelize.fn('array_append', sequelize.col('related_orgs'), relatedOrg), }, { - where: { - id: ids, - [Op.or]: [ - { - [Op.not]: { - related_orgs: { - [Op.contains]: [relatedOrg], - }, - }, - }, - { - related_orgs: { - [Op.is]: null, - }, - }, - ], - }, + where: whereClause, ...options, individualHooks: true, } ) - const [rowsAffected, updatedRows] = result return options.returning ? { rowsAffected, updatedRows } : rowsAffected } catch (error) { @@ -148,13 +149,17 @@ exports.appendRelatedOrg = async (relatedOrg, ids, options = {}) => { } } -exports.removeRelatedOrg = async (removedOrgIds, ids, options = {}) => { +// (removedOrgIds, ids, tenantCode, options = {}) +exports.removeRelatedOrg = async (removedOrgIds, ids, tenantCode, options = {}) => { try { const result = await Organization.update( - { related_orgs: sequelize.fn('array_remove', sequelize.col('related_orgs'), removedOrgIds) }, + { + related_orgs: sequelize.fn('array_remove', sequelize.col('related_orgs'), removedOrgIds), + }, { where: { id: ids, + tenant_code: tenantCode, // enforce same tenant }, ...options, individualHooks: true, @@ -168,6 +173,7 @@ exports.removeRelatedOrg = async (removedOrgIds, ids, options = {}) => { throw error } } + exports.listOrganizations = async (page, limit, search) => { try { let filterQuery = { diff --git a/src/services/organization.js b/src/services/organization.js index c83a854b0..dc5459d05 100644 --- a/src/services/organization.js +++ b/src/services/organization.js @@ -19,6 +19,7 @@ const { eventBodyDTO } = require('@dtos/eventBody') const organizationDTO = require('@dtos/organizationDTO') const responses = require('@helpers/responses') const userOrgQueries = require('@database/queries/userOrganization') +const cacheClient = require('@generics/cacheHelper') module.exports = class OrganizationsHelper { /** @@ -177,7 +178,7 @@ module.exports = class OrganizationsHelper { * @returns {JSON} - Update Organization data. */ - static async update(id, bodyData, loggedInUserId) { + static async update(id, bodyData, loggedInUserId, tenantCode) { try { bodyData.updated_by = loggedInUserId if (bodyData.relatedOrgs) { @@ -192,7 +193,24 @@ module.exports = class OrganizationsHelper { }) } const orgDetails = await organizationQueries.update({ id: id }, bodyData, { returning: true, raw: true }) - + await cacheClient + .invalidateOrgNamespaceVersion({ + tenantCode, + orgId: orgDetailsBeforeUpdate.code, + ns: common.CACHE_CONFIG.namespaces.organization.name, + }) + .catch((error) => { + console.error(error) + }) + await cacheClient + .invalidateOrgNamespaceVersion({ + tenantCode, + orgId: orgDetailsBeforeUpdate.code, + ns: common.CACHE_CONFIG.namespaces.profile.name, + }) + .catch((error) => { + console.error(error) + }) let domains = [] if (bodyData.domains?.length) { let existingDomains = await orgDomainQueries.findAll({ @@ -501,10 +519,10 @@ module.exports = class OrganizationsHelper { } } - static async addRelatedOrg(id, relatedOrgs = []) { + static async addRelatedOrg(id, relatedOrgs = [], tenantCode) { try { - // fetch organization details before update - const orgDetailsBeforeUpdate = await organizationQueries.findOne({ id }) + // fetch organization details before update (ensures tenant_code checked) + const orgDetailsBeforeUpdate = await organizationQueries.findOne({ id, tenant_code: tenantCode }) if (!orgDetailsBeforeUpdate) { return responses.failureResponse({ statusCode: httpStatusCode.not_acceptable, @@ -512,15 +530,44 @@ module.exports = class OrganizationsHelper { message: 'ORGANIZATION_NOT_FOUND', }) } - // append related organizations and make sure it is unique - let newRelatedOrgs = [...new Set([...(orgDetailsBeforeUpdate?.related_orgs ?? []), ...relatedOrgs])] + + // normalize and remove self if present + let requestedRelated = Array.isArray(relatedOrgs) ? relatedOrgs.map(Number) : [] + requestedRelated = [...new Set(requestedRelated)].filter((rid) => rid && rid !== Number(id)) + + if (requestedRelated.length === 0) { + return responses.successResponse({ + statusCode: httpStatusCode.accepted, + message: 'ORGANIZATION_UPDATED_SUCCESSFULLY', + }) + } + + const relatedOrgRecords = await organizationQueries.findAll( + { id: requestedRelated, tenant_code: tenantCode }, + { raw: true } + ) + + // not found or tenant mismatch (because tenant_code filter applied) + const foundIds = relatedOrgRecords.map((r) => Number(r.id)) + const notFound = requestedRelated.filter((rid) => !foundIds.includes(Number(rid))) + if (notFound.length) { + return responses.failureResponse({ + statusCode: httpStatusCode.not_acceptable, + responseCode: 'CLIENT_ERROR', + message: `RELATED_ORGANIZATIONS_NOT_FOUND`, + }) + } + + // append related organizations and make sure unique (local copy) + let newRelatedOrgs = [...new Set([...(orgDetailsBeforeUpdate?.related_orgs ?? []), ...requestedRelated])] // check if there are any addition to related_org if (!_.isEqual(orgDetailsBeforeUpdate?.related_orgs, newRelatedOrgs)) { - // update org related orgs - await organizationQueries.update( + // update org related orgs (scoped by tenant) + const updatedOrg = await organizationQueries.update( { id, + tenant_code: tenantCode, }, { related_orgs: newRelatedOrgs, @@ -530,13 +577,44 @@ module.exports = class OrganizationsHelper { raw: true, } ) - // update related orgs to append org Id - await organizationQueries.appendRelatedOrg(id, newRelatedOrgs, { + + // update related orgs to append org Id. scoped to same tenant + const updatedRelatedOrgs = await organizationQueries.appendRelatedOrg(id, newRelatedOrgs, tenantCode, { returning: true, raw: true, }) + const deltaOrgs = _.difference(newRelatedOrgs, orgDetailsBeforeUpdate?.related_orgs) + // build list of orgs with their tenant codes + const orgsToInvalidateRecords = [...updatedOrg.updatedRows, ...updatedRelatedOrgs.updatedRows].map( + (r) => ({ + orgId: r.code, + tenantCode: r.tenant_code, + }) + ) + + const { organization, profile } = common.CACHE_CONFIG.namespaces + const { invalidateOrgNamespaceVersion } = cacheClient + + const tasks = orgsToInvalidateRecords.flatMap(({ orgId, tenantCode: tCode }) => + [organization.name, profile.name].map((ns) => ({ + orgId, + tenantCode: tCode, + ns, + promise: invalidateOrgNamespaceVersion({ tenantCode: tCode, orgId, ns }), + })) + ) + + const results = await Promise.allSettled(tasks.map((t) => t.promise)) + + results.forEach((res, i) => { + if (res.status === 'rejected') { + const { orgId, tenantCode, ns } = tasks[i] + console.error(`invalidate failed for org ${orgId} (tenant ${tenantCode}) ns: ${ns}`, res.reason) + } + }) + eventBroadcaster('updateRelatedOrgs', { requestBody: { delta_organization_ids: deltaOrgs, @@ -551,13 +629,15 @@ module.exports = class OrganizationsHelper { message: 'ORGANIZATION_UPDATED_SUCCESSFULLY', }) } catch (error) { + console.log(error) throw error } } - static async removeRelatedOrg(id, relatedOrgs = []) { + + static async removeRelatedOrg(id, relatedOrgs = [], tenantCode) { try { // fetch organization details before update - const orgDetailsBeforeUpdate = await organizationQueries.findOne({ id }) + const orgDetailsBeforeUpdate = await organizationQueries.findOne({ id, tenant_code: tenantCode }) if (!orgDetailsBeforeUpdate) { return responses.failureResponse({ statusCode: httpStatusCode.not_acceptable, @@ -565,50 +645,76 @@ module.exports = class OrganizationsHelper { message: 'ORGANIZATION_NOT_FOUND', }) } - if (orgDetailsBeforeUpdate?.related_orgs == null) { + if ( + !Array.isArray(orgDetailsBeforeUpdate.related_orgs) || + orgDetailsBeforeUpdate.related_orgs.length === 0 + ) { return responses.failureResponse({ statusCode: httpStatusCode.not_acceptable, responseCode: 'CLIENT_ERROR', message: 'RELATED_ORG_REMOVAL_FAILED', }) } - const relatedOrganizations = _.difference(orgDetailsBeforeUpdate?.related_orgs, relatedOrgs) - // check if the given org ids are present in the organization's related org - const relatedOrgMismatchFlag = relatedOrgs.some( - (orgId) => !orgDetailsBeforeUpdate?.related_orgs.includes(orgId) - ) - - if (relatedOrgMismatchFlag) { + // ensure the orgs to remove exist in same tenant + const relatedOrgRecords = await organizationQueries.findAll({ + where: { id: relatedOrgs, tenant_code: tenantCode }, + raw: true, + }) + const foundIds = relatedOrgRecords.map((r) => Number(r.id)) + const notFound = relatedOrgs.filter((rid) => !foundIds.includes(Number(rid))) + if (notFound.length) { return responses.failureResponse({ statusCode: httpStatusCode.not_acceptable, responseCode: 'CLIENT_ERROR', - message: 'RELATED_ORG_REMOVAL_FAILED', + message: `RELATED_ORG_REMOVAL_FAILED_NOT_FOUND_OR_DIFFERENT_TENANT: ${notFound.join(',')}`, }) } - // check if there are any addition to related_org - if (!_.isEqual(orgDetailsBeforeUpdate?.related_orgs, relatedOrganizations)) { - // update org remove related orgs - await organizationQueries.update( - { - id: parseInt(id, 10), - }, - { - related_orgs: relatedOrganizations, - }, - { - returning: true, - raw: true, - } + // recalc related orgs for this org + const newRelated = _.difference(orgDetailsBeforeUpdate.related_orgs, relatedOrgs) + + if (!_.isEqual(orgDetailsBeforeUpdate.related_orgs, newRelated)) { + // update this org + const updatedOrg = await organizationQueries.update( + { id, tenant_code: tenantCode }, + { related_orgs: newRelated }, + { returning: true, raw: true } ) - // update related orgs remove orgId - await organizationQueries.removeRelatedOrg(id, relatedOrgs, { + // update reverse side (other orgs remove this org's id), scoped by tenant + const updatedRelatedOrgs = await organizationQueries.removeRelatedOrg(id, relatedOrgs, tenantCode, { returning: true, raw: true, }) + // invalidate cache and broadcast event + const orgsToInvalidateRecords = [...updatedOrg.updatedRows, ...updatedRelatedOrgs.updatedRows].map( + (r) => ({ + orgId: r.code, + tenantCode: r.tenant_code, + }) + ) + const { organization, profile } = common.CACHE_CONFIG.namespaces + const { invalidateOrgNamespaceVersion } = cacheClient + + const tasks = orgsToInvalidateRecords.flatMap(({ orgId, tenantCode: tCode }) => + [organization.name, profile.name].map((ns) => ({ + orgId, + tenantCode: tCode, + ns, + promise: invalidateOrgNamespaceVersion({ tenantCode: tCode, orgId, ns }), + })) + ) + + const results = await Promise.allSettled(tasks.map((t) => t.promise)) + results.forEach((res, i) => { + if (res.status === 'rejected') { + const { orgId, tenantCode, ns } = tasks[i] + console.error(`invalidate failed for org ${orgId} (tenant ${tenantCode}) ns: ${ns}`, res.reason) + } + }) + eventBroadcaster('updateRelatedOrgs', { requestBody: { delta_organization_ids: relatedOrgs, From 4099fffc7cdd74ecfeb9c15b7bfa11d8295a7b26 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 22 Sep 2025 20:52:44 +0530 Subject: [PATCH 18/36] feat: update user role service to include organization code and invalidate cache on role updates and deletions --- src/constants/common.js | 8 +++--- src/controllers/v1/user-role.js | 2 ++ src/services/user-role.js | 46 +++++++++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/constants/common.js b/src/constants/common.js index 77abf15d5..a8b10d135 100644 --- a/src/constants/common.js +++ b/src/constants/common.js @@ -119,11 +119,11 @@ module.exports = { shards: 32, versionDefault: 0, namespaces: { - profile: { name: 'profile', enabled: true, defaultTtl: 43200, useInternal: false }, + profile: { name: 'profile', enabled: true, defaultTtl: 3600, useInternal: false }, entity_types: { name: 'entity_types', enabled: true, defaultTtl: 86400, useInternal: false }, - tenant: { name: 'tenant', enabled: true, defaultTtl: 86400, useInternal: false }, - branding: { name: 'branding', enabled: true, defaultTtl: 86400, useInternal: false }, - organization: { name: 'organization', enabled: true, defaultTtl: 86400, useInternal: false }, + tenant: { name: 'tenant', enabled: true, defaultTtl: 21600, useInternal: false }, + branding: { name: 'branding', enabled: true, defaultTtl: 43200, useInternal: false }, + organization: { name: 'organization', enabled: true, defaultTtl: 21600, useInternal: false }, }, }, } diff --git a/src/controllers/v1/user-role.js b/src/controllers/v1/user-role.js index 7bd0dc4ec..aa2658e9b 100644 --- a/src/controllers/v1/user-role.js +++ b/src/controllers/v1/user-role.js @@ -61,6 +61,7 @@ module.exports = class userRole { req.params.id, req.body, req.decodedToken.organization_id, + req.decodedToken.organization_code, req.decodedToken.tenant_code ) return updateRole @@ -83,6 +84,7 @@ module.exports = class userRole { return await roleService.delete( req.params.id, req.decodedToken.organization_id, + req.decodedToken.organization_code, req.decodedToken.tenant_code ) } catch (error) { diff --git a/src/services/user-role.js b/src/services/user-role.js index 4f0d93133..2f021a73e 100644 --- a/src/services/user-role.js +++ b/src/services/user-role.js @@ -14,6 +14,7 @@ const { Op } = require('sequelize') const organizationQueries = require('@database/queries/organization') const responses = require('@helpers/responses') const utils = require('@generics/utils') +const cacheClient = require('@generics/cacheHelper') module.exports = class userRoleHelper { /** @@ -78,7 +79,7 @@ module.exports = class userRoleHelper { * @returns {Promise} - Updated role response. */ - static async update(id, bodyData, userOrganizationId, tenantCode) { + static async update(id, bodyData, userOrganizationId, userOrganizationCode, tenantCode) { try { const filter = { id: id, organization_id: userOrganizationId, tenant_code: tenantCode } const [updateCount, updateRole] = await roleQueries.updateRole(filter, bodyData) @@ -89,11 +90,32 @@ module.exports = class userRoleHelper { responseCode: 'CLIENT_ERROR', }) } + + await cacheClient + .invalidateOrgNamespaceVersion({ + tenantCode, + orgId: userOrganizationCode, + ns: common.CACHE_CONFIG.namespaces.organization.name, + }) + .catch((error) => { + console.error(error) + }) + await cacheClient + .invalidateOrgNamespaceVersion({ + tenantCode, + orgId: userOrganizationCode, + ns: common.CACHE_CONFIG.namespaces.profile.name, + }) + .catch((error) => { + console.error(error) + }) + return responses.successResponse({ statusCode: httpStatusCode.created, message: 'ROLE_UPDATED_SUCCESSFULLY', result: { title: updateRole[0].title, + label: updateRole[0].label, user_type: updateRole[0].user_type, status: updateRole[0].status, visibility: updateRole[0].visibility, @@ -123,7 +145,7 @@ module.exports = class userRoleHelper { * @returns {Promise} - Deletion result response. */ - static async delete(id, userOrganizationId, tenantCode) { + static async delete(id, userOrganizationId, userOrganizationCode, tenantCode) { try { const filter = { id: id, organization_id: userOrganizationId, tenant_code: tenantCode } const deleteRole = await roleQueries.deleteRole(filter) @@ -136,6 +158,26 @@ module.exports = class userRoleHelper { }) } + await cacheClient + .invalidateOrgNamespaceVersion({ + tenantCode, + orgId: userOrganizationCode, + ns: common.CACHE_CONFIG.namespaces.organization.name, + }) + .catch((error) => { + console.error(error) + }) + await cacheClient + .invalidateOrgNamespaceVersion({ + tenantCode, + orgId: userOrganizationCode, + orgId: userOrganizationCode, + ns: common.CACHE_CONFIG.namespaces.profile.name, + }) + .catch((error) => { + console.error(error) + }) + return responses.successResponse({ statusCode: httpStatusCode.accepted, message: 'ROLE_DELETED_SUCCESSFULLY', From 1e45f47f0444e47173629c5682a4e2e8f15e2eb5 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 22 Sep 2025 20:53:12 +0530 Subject: [PATCH 19/36] feat: add cache invalidation for organization namespace version in account service --- src/generics/cacheHelper.js | 4 ++-- src/services/account.js | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/generics/cacheHelper.js b/src/generics/cacheHelper.js index 0463c2df7..7f80aaf41 100644 --- a/src/generics/cacheHelper.js +++ b/src/generics/cacheHelper.js @@ -141,7 +141,7 @@ async function bumpVersion({ tenantCode, orgId, ns } = {}) { if (redis && typeof redis.incr === 'function') { // atomic increment const newVer = await redis.incr(vKey) - // ensure wrapper reflects new value (best-effort) + // Ensure wrapper reflects new value (best-effort) try { if (RedisCache && typeof RedisCache.setKey === 'function') { await RedisCache.setKey(vKey, String(newVer)) @@ -173,7 +173,7 @@ async function bumpVersion({ tenantCode, orgId, ns } = {}) { /** Build final key with version token inserted so patterns still match. */ async function buildVersionedKey({ tenantCode, orgId, ns, id, key }) { - // If caller provided ns or id, treat as namespaced. Matches previous behaviour: + // If caller provided ns or id, treat as namespaced. Matches previous behavior: // previous code used ns || id ? namespacedKey({ ns: ns || 'ns', id: id||key }) : tenantKey(tenantCode, [key]) const isNamespaced = Boolean(ns || id) if (isNamespaced) { diff --git a/src/services/account.js b/src/services/account.js index 631fd79f4..ba6d6dafb 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -557,6 +557,12 @@ module.exports = class AccountHelper { org_admin: orgAdmins, } ) + + cacheClient.invalidateOrgNamespaceVersion({ + tenantCode: tenantDetail.code, + orgId: user.organization_id, + ns: common.CACHE_CONFIG.namespaces.organization.name, + }) } const result = { access_token: accessToken, refresh_token: refreshToken, user } From 887493fde1f4e8aeb6d89e2a86f7ee212661b35c Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 22 Sep 2025 20:53:48 +0530 Subject: [PATCH 20/36] feat: implement cache invalidation for organization and profile namespaces in admin service --- src/services/admin.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/services/admin.js b/src/services/admin.js index 21a44139c..d018a9ba9 100644 --- a/src/services/admin.js +++ b/src/services/admin.js @@ -37,6 +37,7 @@ const { eventBroadcaster } = require('@helpers/eventBroadcaster') const { generateUniqueUsername } = require('@utils/usernameGenerator') const responses = require('@helpers/responses') const userHelper = require('@helpers/userHelper') +const cacheClient = require('@generics/cacheHelper') // DTOs const UserTransformDTO = require('@dtos/userDTO') @@ -600,6 +601,20 @@ module.exports = class AdminHelper { { attributes: { exclude: ['created_at', 'updated_at', 'deleted_at'] } } ) + cacheClient.invalidateOrgNamespaceVersion({ + tenantCode, + orgId: organizationId, + ns: common.CACHE_CONFIG.namespaces.organization.name, + }) + + const cacheKey = cacheClient.namespacedKey({ + tenantCode, + orgId: organizationId, + ns: common.CACHE_CONFIG.namespaces.profile.name, + id: user.id, + }) + await cacheClient.del(cacheKey) + // Broadcast event asynchronously setImmediate(() => eventBroadcaster('updateOrganization', { @@ -685,6 +700,21 @@ module.exports = class AdminHelper { true // so we can get the user IDs ) + const namespaces = [ + common.CACHE_CONFIG.namespaces.organization.name, + common.CACHE_CONFIG.namespaces.profile.name, + ] + + await Promise.allSettled( + namespaces.map((ns) => cacheClient.invalidateOrgNamespaceVersion({ tenantCode, orgId, ns })) + ).then((results) => + results.forEach((r, i) => { + if (r.status === 'rejected') { + console.error(`invalidate failed for ns=${namespaces[i]} org=${orgId}`, r.reason) + } + }) + ) + // 3. Broadcast & remove sessions if users were found if (userRowsAffected > 0) { const userIds = updatedUsers.map((u) => u.id) From f857f40a7558678fbd108a98f0912ee623a81677 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 22 Sep 2025 20:55:03 +0530 Subject: [PATCH 21/36] feat: add cache invalidation for tenant updates in tenant helper --- src/services/public.js | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/services/public.js b/src/services/public.js index 2a6a9f135..b05a4b9bf 100644 --- a/src/services/public.js +++ b/src/services/public.js @@ -12,6 +12,7 @@ const { Op } = require('sequelize') const UserTransformDTO = require('@dtos/userDTO') const emailEncryption = require('@utils/emailEncryption') const common = require('@constants/common') +const cacheClient = require('@generics/cacheHelper') module.exports = class AccountHelper { static async tenantBranding(domain = null, organizationCode, tenantCode = null) { @@ -32,16 +33,28 @@ module.exports = class AccountHelper { code = tenantDomain?.tenant_code } if (!code) return notFoundResponse('TENANT_NOT_FOUND_PING_ADMIN') - const tenantDetail = await tenantQueries.findOne({ code }, {}) + + const tenantDetail = await cacheClient.getOrSet({ + tenantCode: code, // ensures key is tenant-scoped + ns: common.CACHE_CONFIG.namespaces.tenant.name, + id: code, // unique per tenant + fetchFn: () => tenantQueries.findOne({ code }, {}), + }) if (!tenantDetail) { return notFoundResponse('TENANT_NOT_FOUND_PING_ADMIN') } let orgDetails if (organizationCode) { - orgDetails = await organizationQueries.findOne({ - code: organizationCode, - tenant_code: tenantDomain.tenant_code, + orgDetails = await cacheClient.getOrSet({ + tenantCode: code, // ensures key is tenant-scoped + ns: common.CACHE_CONFIG.namespaces.organization.name, + id: organizationCode, // unique per tenant + fetchFn: () => + organizationQueries.findOne({ + code: organizationCode, + tenant_code: code, + }), }) } return responses.successResponse({ @@ -77,10 +90,12 @@ module.exports = class AccountHelper { return notFoundResponse('TENANT_DOMAIN_NOT_FOUND_PING_ADMIN') } - const tenantDetail = await tenantQueries.findOne( - { code: tenantDomain.tenant_code }, - { attributes: ['code'] } - ) + const tenantDetail = await cacheClient.getOrSet({ + tenantCode: code, // ensures key is tenant-scoped + ns: common.CACHE_CONFIG.namespaces.tenant.name, + id: code, // unique per tenant + fetchFn: () => tenantQueries.findOne({ code: tenantDomain.tenant_code }, {}), + }) if (!tenantDetail) { return notFoundResponse('TENANT_NOT_FOUND_PING_ADMIN') From 112fcbca80174ec8244b99c266e7fac42c13d88b Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 22 Sep 2025 20:55:11 +0530 Subject: [PATCH 22/36] feat: add cache invalidation for tenant updates in tenant helper --- src/services/tenant.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/services/tenant.js b/src/services/tenant.js index 6562394a9..480009719 100644 --- a/src/services/tenant.js +++ b/src/services/tenant.js @@ -30,6 +30,7 @@ const utils = require('@generics/utils') const _ = require('lodash') const responses = require('@helpers/responses') const { Op } = require('sequelize') +const cacheClient = require('@generics/cacheHelper') module.exports = class tenantHelper { /** @@ -438,6 +439,7 @@ module.exports = class tenantHelper { tenantUpdateBody ) } + await cacheClient.invalidateTenantVersion({ tenantCode }).catch(() => {}) return responses.successResponse({ statusCode: httpStatusCode.accepted, @@ -519,6 +521,7 @@ module.exports = class tenantHelper { result: {}, }) } + await cacheClient.invalidateTenantVersion({ tenantCode }).catch(() => {}) return responses.successResponse({ statusCode: httpStatusCode.accepted, @@ -632,6 +635,7 @@ module.exports = class tenantHelper { }) await Promise.all(domainRemovePromise) + await cacheClient.invalidateTenantVersion({ tenantCode }).catch(() => {}) return responses.successResponse({ statusCode: httpStatusCode.accepted, From f640ade0c4517b1398490867270bc752494e0717 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 22 Sep 2025 20:55:17 +0530 Subject: [PATCH 23/36] feat: add message for related organizations not found in locale file --- src/locales/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/locales/en.json b/src/locales/en.json index 21726f8aa..de0648a80 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -137,6 +137,7 @@ "TENANT_DOMAIN_ALREADY_EXISTS": "Tenant domain already exists.", "TENANT_CREATED_SUCCESSFULLY": "Tenant created successfully.", "DEFAULT_ORG_CREATION_FAILED": "Default Organization creation under tenant failed.", + "RELATED_ORGANIZATIONS_NOT_FOUND": "Related organisations not found!", "TENANT_NOT_FOUND": "Tenant not found.", "TENANT_UPDATED_SUCCESSFULLY": "Tenant updated successfully.", "TENANT_DOMAINS_ALREADY_PRESENT": "All the domains are already added for the tenant", From 0b57cc1f0cb403f818ef4403ea53eec05e85e595 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 22 Sep 2025 21:11:54 +0530 Subject: [PATCH 24/36] feat: ensure organization ID is included in cache key for organization details --- src/services/public.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/public.js b/src/services/public.js index b05a4b9bf..88a892ff7 100644 --- a/src/services/public.js +++ b/src/services/public.js @@ -48,6 +48,7 @@ module.exports = class AccountHelper { if (organizationCode) { orgDetails = await cacheClient.getOrSet({ tenantCode: code, // ensures key is tenant-scoped + orgId: organizationCode, // ensures key is tenant-scoped ns: common.CACHE_CONFIG.namespaces.organization.name, id: organizationCode, // unique per tenant fetchFn: () => From 04425bd54255b4c3ad15784a407aed2a35ffa5c3 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Mon, 22 Sep 2025 21:25:37 +0530 Subject: [PATCH 25/36] feat: add optional chaining for organizationAttributes and implement cache invalidation for user organization deletions --- src/database/queries/userOrganization.js | 2 +- src/helpers/userHelper.js | 24 +++++++++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/database/queries/userOrganization.js b/src/database/queries/userOrganization.js index 1904c662c..7a9bb067a 100644 --- a/src/database/queries/userOrganization.js +++ b/src/database/queries/userOrganization.js @@ -106,7 +106,7 @@ exports.findOne = async (filter, options = {}) => { exports.findAll = async (filter = {}, options = {}) => { try { - if (options.organizationAttributes.length > 0) { + if (options?.organizationAttributes?.length > 0) { options.include = [ { model: Organization, diff --git a/src/helpers/userHelper.js b/src/helpers/userHelper.js index 2123eb8a6..289c3f2dc 100644 --- a/src/helpers/userHelper.js +++ b/src/helpers/userHelper.js @@ -69,6 +69,14 @@ const userHelper = { const update = { ...rest, ...generateUpdateParams(userId) } await userQueries.updateUser({ id: user.id }, update, { transaction }) + + // Capture org codes before delete for cache invalidation + const orgRows = await userOrganizationQueries.findAll( + { user_id: user.id, tenant_code: user.tenant_code }, + { attributes: ['organization_code'], transaction } + ) + const orgCodes = [...new Set(orgRows.map((r) => r.organization_code))] + await userOrganizationQueries.delete( { user_id: user.id, tenant_code: user.tenant_code }, { transaction } @@ -94,13 +102,15 @@ const userHelper = { // Clear cache entries for this user try { const ns = common.CACHE_CONFIG.namespaces.profile.name - const fullKey = await cacheClient.versionedKey({ - tenantCode: user.tenant_code, - orgId: user.organization_code, - ns, - id: userId, - }) - await cacheClient.del(fullKey) + for (const orgId of orgCodes) { + const fullKey = await cacheClient.versionedKey({ + tenantCode: user.tenant_code, + orgId, + ns, + id: userId, + }) + await cacheClient.del(fullKey) + } } catch (err) { console.error('Failed to delete user cache', err) } From 931c0dec104c8f2c49f5a75ecc96ff92725c089f Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 23 Sep 2025 00:42:52 +0530 Subject: [PATCH 26/36] fix: improve user instance handling and correct cache invalidation parameters --- src/services/account.js | 2 +- src/services/admin.js | 6 ++++-- src/services/organization.js | 8 ++++---- src/services/user-role.js | 1 - 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/services/account.js b/src/services/account.js index ba6d6dafb..2bf9f07ca 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -761,7 +761,7 @@ module.exports = class AccountHelper { // Find user with org const userInstance = await userQueries.findUserWithOrganization(query, {}, true) - let user = userInstance ? userInstance.toJSON() : null + let user = userInstance ? (userInstance.toJSON ? userInstance.toJSON() : userInstance) : null if (!user) { return responses.failureResponse({ diff --git a/src/services/admin.js b/src/services/admin.js index d018a9ba9..933082764 100644 --- a/src/services/admin.js +++ b/src/services/admin.js @@ -697,7 +697,7 @@ module.exports = class AdminHelper { status: common.INACTIVE_STATUS, updated_by: loggedInUserId, }, - true // so we can get the user IDs + true // So we can get the user IDs ) const namespaces = [ @@ -706,7 +706,9 @@ module.exports = class AdminHelper { ] await Promise.allSettled( - namespaces.map((ns) => cacheClient.invalidateOrgNamespaceVersion({ tenantCode, orgId, ns })) + namespaces.map((ns) => + cacheClient.invalidateOrgNamespaceVersion({ tenantCode, orgId: organizationCode, ns }) + ) ).then((results) => results.forEach((r, i) => { if (r.status === 'rejected') { diff --git a/src/services/organization.js b/src/services/organization.js index dc5459d05..497765bb2 100644 --- a/src/services/organization.js +++ b/src/services/organization.js @@ -657,10 +657,10 @@ module.exports = class OrganizationsHelper { } // ensure the orgs to remove exist in same tenant - const relatedOrgRecords = await organizationQueries.findAll({ - where: { id: relatedOrgs, tenant_code: tenantCode }, - raw: true, - }) + const relatedOrgRecords = await organizationQueries.findAll( + { id: relatedOrgs, tenant_code: tenantCode }, + { raw: true } + ) const foundIds = relatedOrgRecords.map((r) => Number(r.id)) const notFound = relatedOrgs.filter((rid) => !foundIds.includes(Number(rid))) if (notFound.length) { diff --git a/src/services/user-role.js b/src/services/user-role.js index 2f021a73e..b24480c68 100644 --- a/src/services/user-role.js +++ b/src/services/user-role.js @@ -171,7 +171,6 @@ module.exports = class userRoleHelper { .invalidateOrgNamespaceVersion({ tenantCode, orgId: userOrganizationCode, - orgId: userOrganizationCode, ns: common.CACHE_CONFIG.namespaces.profile.name, }) .catch((error) => { From 89eada38ed8c212577174639df42f21ee5ec69ea Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 23 Sep 2025 01:03:33 +0530 Subject: [PATCH 27/36] feat: add namespace parameter to invalidateTenantVersion for improved cache management --- src/generics/cacheHelper.js | 4 ++-- src/services/public.js | 2 +- src/services/tenant.js | 12 +++++++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/generics/cacheHelper.js b/src/generics/cacheHelper.js index 7f80aaf41..75f2a2b75 100644 --- a/src/generics/cacheHelper.js +++ b/src/generics/cacheHelper.js @@ -367,9 +367,9 @@ async function invalidateNamespaceVersion({ tenantCode, orgId = null, ns } = {}) if (!tenantCode || !ns) return return bumpVersion({ tenantCode, orgId, ns }) } -async function invalidateTenantVersion({ tenantCode } = {}) { +async function invalidateTenantVersion({ tenantCode, ns } = {}) { if (!tenantCode) return - return bumpVersion({ tenantCode }) + return bumpVersion({ tenantCode, ns }) } async function invalidateOrgNamespaceVersion({ tenantCode, orgId, ns } = {}) { if (!tenantCode || !orgId || !ns) return diff --git a/src/services/public.js b/src/services/public.js index 88a892ff7..11b4c5663 100644 --- a/src/services/public.js +++ b/src/services/public.js @@ -59,7 +59,7 @@ module.exports = class AccountHelper { }) } return responses.successResponse({ - statusCode: httpStatusCode.created, + statusCode: httpStatusCode.ok, message: 'TENANT_DETAILS', result: tenantTransformDTO.publicTransform({ tenant: tenantDetail, diff --git a/src/services/tenant.js b/src/services/tenant.js index 480009719..04ea9dacd 100644 --- a/src/services/tenant.js +++ b/src/services/tenant.js @@ -439,7 +439,9 @@ module.exports = class tenantHelper { tenantUpdateBody ) } - await cacheClient.invalidateTenantVersion({ tenantCode }).catch(() => {}) + await cacheClient + .invalidateTenantVersion({ tenantCode, ns: common.CACHE_CONFIG.namespaces.tenant.name }) + .catch(() => {}) return responses.successResponse({ statusCode: httpStatusCode.accepted, @@ -521,7 +523,9 @@ module.exports = class tenantHelper { result: {}, }) } - await cacheClient.invalidateTenantVersion({ tenantCode }).catch(() => {}) + await cacheClient + .invalidateTenantVersion({ tenantCode, ns: common.CACHE_CONFIG.namespaces.tenant.name }) + .catch(() => {}) return responses.successResponse({ statusCode: httpStatusCode.accepted, @@ -635,7 +639,9 @@ module.exports = class tenantHelper { }) await Promise.all(domainRemovePromise) - await cacheClient.invalidateTenantVersion({ tenantCode }).catch(() => {}) + await cacheClient + .invalidateTenantVersion({ tenantCode, ns: common.CACHE_CONFIG.namespaces.tenant.name }) + .catch(() => {}) return responses.successResponse({ statusCode: httpStatusCode.accepted, From 428ac4c5e4185f47eb72d9c7b38b0e27f3fb05c3 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 23 Sep 2025 01:35:50 +0530 Subject: [PATCH 28/36] refactor: replace cache invalidation with eviction by pattern for entity types and profiles --- src/services/account.js | 9 +-------- src/services/entities.js | 8 +++++--- src/services/entityType.js | 10 ++++------ 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/services/account.js b/src/services/account.js index 2bf9f07ca..2aebfa7e4 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -695,14 +695,7 @@ module.exports = class AccountHelper { responseCode: 'CLIENT_ERROR', }) - // Cache lookup for tenant domain - const tenantDomain = await cacheClient.getOrSet({ - tenantCode: null, - orgId: null, - ns: common.CACHE_CONFIG.namespaces.tenant.name, - id: domain, - fetchFn: async () => tenantDomainQueries.findOne({ domain }), - }) + const tenantDomain = await tenantDomainQueries.findOne({ domain }) if (!tenantDomain) { return makeNotFoundResponse('TENANT_DOMAIN_NOT_FOUND_PING_ADMIN') diff --git a/src/services/entities.js b/src/services/entities.js index 17c41cdb7..7783e2a6c 100644 --- a/src/services/entities.js +++ b/src/services/entities.js @@ -22,9 +22,11 @@ module.exports = class EntityHelper { }) if (process.env.DEFAULT_ORGANISATION_CODE === organizationCode) { - await cacheClient.invalidateNamespaceVersion({ - tenantCode, - ns: common.CACHE_CONFIG.namespaces.entity_types.name, + await cacheClient.evictTenantByPattern(tenantCode, { + patternSuffix: `org:*:${common.CACHE_CONFIG.namespaces.entity_types.name}:*`, + }) + await cacheClient.evictTenantByPattern(tenantCode, { + patternSuffix: `org:*:${common.CACHE_CONFIG.namespaces.profile.name}:*`, }) } } catch (err) { diff --git a/src/services/entityType.js b/src/services/entityType.js index 9e3d6ed2c..d0d62201a 100644 --- a/src/services/entityType.js +++ b/src/services/entityType.js @@ -25,13 +25,11 @@ module.exports = class EntityHelper { }) if (process.env.DEFAULT_ORGANISATION_CODE === organizationCode) { - await cacheClient.invalidateNamespaceVersion({ - tenantCode, - ns: common.CACHE_CONFIG.namespaces.entity_types.name, + await cacheClient.evictTenantByPattern(tenantCode, { + patternSuffix: `org:*:${common.CACHE_CONFIG.namespaces.entity_types.name}:*`, }) - await cacheClient.invalidateNamespaceVersion({ - tenantCode, - ns: common.CACHE_CONFIG.namespaces.profile.name, + await cacheClient.evictTenantByPattern(tenantCode, { + patternSuffix: `org:*:${common.CACHE_CONFIG.namespaces.profile.name}:*`, }) } } catch (err) { From 2c59b2ec822182eb1ffdf1dca49f9c335ea50f35 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 23 Sep 2025 01:37:41 +0530 Subject: [PATCH 29/36] fix: update cache invalidation to use organization code instead of user organization ID --- src/services/account.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/account.js b/src/services/account.js index 2aebfa7e4..6089b6064 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -560,7 +560,7 @@ module.exports = class AccountHelper { cacheClient.invalidateOrgNamespaceVersion({ tenantCode: tenantDetail.code, - orgId: user.organization_id, + orgId: organization.code, ns: common.CACHE_CONFIG.namespaces.organization.name, }) } From 5b4d81f81fe23352c5e8d06fd8398be843b3400c Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 23 Sep 2025 15:12:05 +0530 Subject: [PATCH 30/36] refactor: replace versioned key handling with simple key builder and update cache invalidation methods --- src/generics/cacheHelper.js | 163 +++++------------------------------ src/helpers/userHelper.js | 2 +- src/services/account.js | 4 +- src/services/admin.js | 20 ++--- src/services/entities.js | 4 +- src/services/entityType.js | 4 +- src/services/organization.js | 12 +-- src/services/tenant.js | 15 +++- src/services/user-role.js | 8 +- src/services/user.js | 8 +- 10 files changed, 67 insertions(+), 173 deletions(-) diff --git a/src/generics/cacheHelper.js b/src/generics/cacheHelper.js index 75f2a2b75..fb47aec28 100644 --- a/src/generics/cacheHelper.js +++ b/src/generics/cacheHelper.js @@ -19,10 +19,6 @@ const SHARDS = toInt(CACHE_CONFIG.shards, 32) const BATCH = toInt(CACHE_CONFIG.scanBatch, 1000) const SHARD_RETENTION_DAYS = toInt(CACHE_CONFIG.shardRetentionDays, 7) -// Version config -const VERSION_CACHE_TTL = toInt(CACHE_CONFIG.versionCacheTtlSeconds, 5) // seconds, short in-process TTL -const VERSION_DEFAULT = toInt(CACHE_CONFIG.versionDefault || 0, 0) - /** Helpers */ function toInt(v, d) { const n = parseInt(v, 10) @@ -77,18 +73,28 @@ function namespacedKey({ tenantCode, orgId, ns, id }) { return [base, ns, id].filter(Boolean).join(':') } -async function versionedKey({ tenantCode, orgId, ns, id, key }) { - return buildVersionedKey({ tenantCode, orgId, ns, id, key }) +/** New simple key builder (no version tokens) */ +async function buildKey({ tenantCode, orgId, ns, id, key }) { + // If caller provided ns or id, treat as namespaced. + const isNamespaced = Boolean(ns || id) + if (isNamespaced) { + const effNs = ns || 'ns' + const base = orgId ? orgKey(tenantCode, orgId, []) : tenantKey(tenantCode, []) + const final = [base, effNs, id || key].filter(Boolean).join(':') + return final + } + // tenant-level key + const base = tenantKey(tenantCode, []) + const final = [base, key].filter(Boolean).join(':') + return final } + function shardOf(key) { const h = md5(key) const asInt = parseInt(h.slice(0, 8), 16) return (asInt >>> 0) % SHARDS } -/** In-process short cache for version lookups */ -const _versionCache = new Map() // key -> { ver: number, expiresAt: timestamp } - /** Low-level redis client (best-effort) */ function getRedisClient() { try { @@ -98,98 +104,6 @@ function getRedisClient() { } } -/** Version key name resolution */ -function versionKeyName({ tenantCode, orgId, ns }) { - if (tenantCode && orgId && ns) return `__version:tenant:${tenantCode}:org:${orgId}:ns:${ns}` - if (tenantCode && ns) return `__version:tenant:${tenantCode}:ns:${ns}` - if (tenantCode) return `__version:tenant:${tenantCode}` - return `__version:global` -} - -/** Get version from in-process cache or Redis (short TTL). */ -async function getVersion({ tenantCode, orgId, ns } = {}) { - const vKey = versionKeyName({ tenantCode, orgId, ns }) - const cached = _versionCache.get(vKey) - if (cached && cached.expiresAt > Date.now()) return cached.ver - - try { - // try Redis via wrapper - if (RedisCache && typeof RedisCache.getKey === 'function') { - const raw = await RedisCache.getKey(vKey) - const ver = raw ? parseInt(raw, 10) || VERSION_DEFAULT : VERSION_DEFAULT - _versionCache.set(vKey, { ver, expiresAt: Date.now() + VERSION_CACHE_TTL * 1000 }) - return ver - } - } catch (e) { - console.error('getVersion redis read error', e) - } - - // fallback default - _versionCache.set(vKey, { ver: VERSION_DEFAULT, expiresAt: Date.now() + VERSION_CACHE_TTL * 1000 }) - return VERSION_DEFAULT -} - -/** - * Bump version atomically for the given level. - * Preferred method is native Redis INCR. Fallback to read+set. - * Returns new version number. - */ -async function bumpVersion({ tenantCode, orgId, ns } = {}) { - const vKey = versionKeyName({ tenantCode, orgId, ns }) - const redis = getRedisClient() - try { - if (redis && typeof redis.incr === 'function') { - // atomic increment - const newVer = await redis.incr(vKey) - // Ensure wrapper reflects new value (best-effort) - try { - if (RedisCache && typeof RedisCache.setKey === 'function') { - await RedisCache.setKey(vKey, String(newVer)) - } - } catch (_) {} - _versionCache.set(vKey, { ver: Number(newVer), expiresAt: Date.now() + VERSION_CACHE_TTL * 1000 }) - return Number(newVer) - } - // fallback: read + increment + set - const currRaw = await RedisCache.getKey(vKey) - const curr = currRaw ? parseInt(currRaw, 10) || VERSION_DEFAULT : VERSION_DEFAULT - const newVer = curr + 1 - await RedisCache.setKey(vKey, String(newVer)) - _versionCache.set(vKey, { ver: newVer, expiresAt: Date.now() + VERSION_CACHE_TTL * 1000 }) - return newVer - } catch (e) { - console.error('bumpVersion error', e) - // as last resort update in-memory and return incremented value - const currCached = _versionCache.get(vKey) - const curr = currCached ? currCached.ver : VERSION_DEFAULT - const newVer = curr + 1 - _versionCache.set(vKey, { ver: newVer, expiresAt: Date.now() + VERSION_CACHE_TTL * 1000 }) - try { - await RedisCache.setKey(vKey, String(newVer)) - } catch (_) {} - return newVer - } -} - -/** Build final key with version token inserted so patterns still match. */ -async function buildVersionedKey({ tenantCode, orgId, ns, id, key }) { - // If caller provided ns or id, treat as namespaced. Matches previous behavior: - // previous code used ns || id ? namespacedKey({ ns: ns || 'ns', id: id||key }) : tenantKey(tenantCode, [key]) - const isNamespaced = Boolean(ns || id) - if (isNamespaced) { - const effNs = ns || 'ns' - const ver = await getVersion({ tenantCode, orgId, ns: effNs }) - const base = orgId ? orgKey(tenantCode, orgId, []) : tenantKey(tenantCode, []) - const final = [base, effNs, `v${ver}`, id || key].filter(Boolean).join(':') - return final - } - // tenant-level key - const ver = await getVersion({ tenantCode }) - const base = tenantKey(tenantCode, []) - const final = [base, `v${ver}`, key].filter(Boolean).join(':') - return final -} - /** Base ops (Internal cache opt-in via config or caller) */ async function get(key, { useInternal = false } = {}) { if (!ENABLE_CACHE) return null @@ -251,18 +165,16 @@ async function del(key, { useInternal = false } = {}) { * - fetchFn: function that returns value * - orgId, ns, id: for namespaced keys * - useInternal: optional boolean override. If omitted, resolved from namespace/config. - * - * NOTE: This function now resolves a versioned key internally. */ async function getOrSet({ key, tenantCode, ttl = undefined, fetchFn, orgId, ns, id, useInternal = undefined }) { if (!namespaceEnabled(ns)) return await fetchFn() const resolvedUseInternal = nsUseInternal(ns, useInternal) - // build versioned key (keeps previous behaviour but adds version token) + // build simple key (no version token) const fullKey = ns || id - ? await buildVersionedKey({ tenantCode, orgId, ns: ns || 'ns', id: id || key }) - : await buildVersionedKey({ tenantCode, key }) + ? await buildKey({ tenantCode, orgId, ns: ns || 'ns', id: id || key }) + : await buildKey({ tenantCode, key }) const cached = await get(fullKey, { useInternal: resolvedUseInternal }) if (cached !== null && cached !== undefined) return cached @@ -275,23 +187,23 @@ async function getOrSet({ key, tenantCode, ttl = undefined, fetchFn, orgId, ns, } /** Scoped set that uses namespace TTL and namespace useInternal setting - * Returns the versioned key that was written. + * Returns the key that was written. */ async function setScoped({ tenantCode, orgId, ns, id, value, ttl = undefined, useInternal = undefined }) { if (!namespaceEnabled(ns)) return null const resolvedUseInternal = nsUseInternal(ns, useInternal) - const fullKey = await buildVersionedKey({ tenantCode, orgId, ns, id }) + const fullKey = await buildKey({ tenantCode, orgId, ns, id }) await set(fullKey, value, nsTtl(ns, ttl), { useInternal: resolvedUseInternal }) return fullKey } /** Scoped delete that uses namespace config (TTL/useInternal) - * Returns the versioned key that was deleted. + * Returns the key that was deleted. */ async function delScoped({ tenantCode, orgId, ns, id, useInternal = undefined }) { if (!namespaceEnabled(ns)) return null const resolvedUseInternal = nsUseInternal(ns, useInternal) - const fullKey = await buildVersionedKey({ tenantCode, orgId, ns, id }) + const fullKey = await buildKey({ tenantCode, orgId, ns, id }) await del(fullKey, { useInternal: resolvedUseInternal }) return fullKey } @@ -300,9 +212,6 @@ async function delScoped({ tenantCode, orgId, ns, id, useInternal = undefined }) * Evict all keys for a namespace. * If orgId is provided will target org-level keys, otherwise tenant-level keys. * patternSuffix defaults to '*' (delete all keys under the namespace). - * - * NOTE: Because version is a token between ns and id, the glob pattern `tenant:acme:users:*` - * will match versioned keys like `tenant:acme:users:v3:...`. */ async function evictNamespace({ tenantCode, orgId = null, ns, patternSuffix = '*' } = {}) { if (!tenantCode || !ns) return @@ -326,17 +235,14 @@ async function scanAndDelete(pattern, { batchSize = BATCH, unlink = true } = {}) if (!redis) return let cursor = '0' do { - // redis.scan(cursor, 'MATCH', pattern, 'COUNT', batchSize) const res = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', batchSize) cursor = res && res[0] ? res[0] : '0' const keys = res && res[1] ? res[1] : [] if (keys.length) { - // try unlink as best-effort try { if (unlink && typeof redis.unlink === 'function') await redis.unlink(...keys) else await redis.del(...keys) } catch (e) { - // fallback to individual deletes for (const k of keys) { try { if (unlink && typeof redis.unlink === 'function') await redis.unlink(k) @@ -362,20 +268,6 @@ async function evictTenantByPattern(tenantCode, { patternSuffix = '*' } = {}) { await scanAndDelete(pattern) } -/** Convenience invalidation by bumping version (fast) */ -async function invalidateNamespaceVersion({ tenantCode, orgId = null, ns } = {}) { - if (!tenantCode || !ns) return - return bumpVersion({ tenantCode, orgId, ns }) -} -async function invalidateTenantVersion({ tenantCode, ns } = {}) { - if (!tenantCode) return - return bumpVersion({ tenantCode, ns }) -} -async function invalidateOrgNamespaceVersion({ tenantCode, orgId, ns } = {}) { - if (!tenantCode || !orgId || !ns) return - return bumpVersion({ tenantCode, orgId, ns }) -} - /** Public API */ module.exports = { // Base ops @@ -388,7 +280,7 @@ module.exports = { // Scoped helpers setScoped, namespacedKey, - versionedKey, + buildKey, // Eviction (pattern based) delScoped, @@ -397,13 +289,6 @@ module.exports = { evictTenantByPattern, scanAndDelete, - // Versioning API - getVersion, - bumpVersion, - invalidateNamespaceVersion, - invalidateTenantVersion, - invalidateOrgNamespaceVersion, - // Introspection _internal: { getRedisClient, @@ -411,7 +296,5 @@ module.exports = { BATCH, ENABLE_CACHE, CACHE_CONFIG, - VERSION_CACHE_TTL, - VERSION_DEFAULT, }, } diff --git a/src/helpers/userHelper.js b/src/helpers/userHelper.js index 289c3f2dc..a49b2ccbe 100644 --- a/src/helpers/userHelper.js +++ b/src/helpers/userHelper.js @@ -103,7 +103,7 @@ const userHelper = { try { const ns = common.CACHE_CONFIG.namespaces.profile.name for (const orgId of orgCodes) { - const fullKey = await cacheClient.versionedKey({ + const fullKey = await cacheClient.buildKey({ tenantCode: user.tenant_code, orgId, ns, diff --git a/src/services/account.js b/src/services/account.js index 6089b6064..696910e84 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -557,11 +557,11 @@ module.exports = class AccountHelper { org_admin: orgAdmins, } ) - - cacheClient.invalidateOrgNamespaceVersion({ + await cacheClient.evictNamespace({ tenantCode: tenantDetail.code, orgId: organization.code, ns: common.CACHE_CONFIG.namespaces.organization.name, + patternSuffix: '*', }) } diff --git a/src/services/admin.js b/src/services/admin.js index 933082764..ecc4ebe15 100644 --- a/src/services/admin.js +++ b/src/services/admin.js @@ -601,15 +601,16 @@ module.exports = class AdminHelper { { attributes: { exclude: ['created_at', 'updated_at', 'deleted_at'] } } ) - cacheClient.invalidateOrgNamespaceVersion({ + await cacheClient.evictNamespace({ tenantCode, - orgId: organizationId, + orgId: organization.code, ns: common.CACHE_CONFIG.namespaces.organization.name, + patternSuffix: '*', }) const cacheKey = cacheClient.namespacedKey({ tenantCode, - orgId: organizationId, + orgId: organization.code, ns: common.CACHE_CONFIG.namespaces.profile.name, id: user.id, }) @@ -705,17 +706,16 @@ module.exports = class AdminHelper { common.CACHE_CONFIG.namespaces.profile.name, ] - await Promise.allSettled( + const results = await Promise.allSettled( namespaces.map((ns) => cacheClient.invalidateOrgNamespaceVersion({ tenantCode, orgId: organizationCode, ns }) ) - ).then((results) => - results.forEach((r, i) => { - if (r.status === 'rejected') { - console.error(`invalidate failed for ns=${namespaces[i]} org=${orgId}`, r.reason) - } - }) ) + results.forEach((r, i) => { + if (r.status === 'rejected') { + console.error(`invalidate failed for ns=${namespaces[i]} org=${organizationCode}`, r.reason) + } + }) // 3. Broadcast & remove sessions if users were found if (userRowsAffected > 0) { diff --git a/src/services/entities.js b/src/services/entities.js index 7783e2a6c..d6abcc094 100644 --- a/src/services/entities.js +++ b/src/services/entities.js @@ -9,13 +9,13 @@ const cacheClient = require('@generics/cacheHelper') module.exports = class EntityHelper { static async _invalidateEntityCaches({ tenantCode, organizationCode }) { try { - await cacheClient.invalidateOrgNamespaceVersion({ + await cacheClient.evictOrgByPattern({ tenantCode, orgId: organizationCode, ns: common.CACHE_CONFIG.namespaces.entity_types.name, }) - await cacheClient.invalidateOrgNamespaceVersion({ + await cacheClient.evictOrgByPattern({ tenantCode, orgId: organizationCode, ns: common.CACHE_CONFIG.namespaces.profile.name, diff --git a/src/services/entityType.js b/src/services/entityType.js index d0d62201a..f16a31969 100644 --- a/src/services/entityType.js +++ b/src/services/entityType.js @@ -12,13 +12,13 @@ const cacheClient = require('@generics/cacheHelper') module.exports = class EntityHelper { static async _invalidateEntityTypeCaches({ tenantCode, organizationCode }) { try { - await cacheClient.invalidateOrgNamespaceVersion({ + await cacheClient.evictNamespace({ tenantCode, orgId: organizationCode, ns: common.CACHE_CONFIG.namespaces.entity_types.name, }) - await cacheClient.invalidateOrgNamespaceVersion({ + await cacheClient.evictNamespace({ tenantCode, orgId: organizationCode, ns: common.CACHE_CONFIG.namespaces.profile.name, diff --git a/src/services/organization.js b/src/services/organization.js index 497765bb2..ac2b8ea75 100644 --- a/src/services/organization.js +++ b/src/services/organization.js @@ -194,7 +194,7 @@ module.exports = class OrganizationsHelper { } const orgDetails = await organizationQueries.update({ id: id }, bodyData, { returning: true, raw: true }) await cacheClient - .invalidateOrgNamespaceVersion({ + .evictNamespace({ tenantCode, orgId: orgDetailsBeforeUpdate.code, ns: common.CACHE_CONFIG.namespaces.organization.name, @@ -203,7 +203,7 @@ module.exports = class OrganizationsHelper { console.error(error) }) await cacheClient - .invalidateOrgNamespaceVersion({ + .evictNamespace({ tenantCode, orgId: orgDetailsBeforeUpdate.code, ns: common.CACHE_CONFIG.namespaces.profile.name, @@ -595,14 +595,14 @@ module.exports = class OrganizationsHelper { ) const { organization, profile } = common.CACHE_CONFIG.namespaces - const { invalidateOrgNamespaceVersion } = cacheClient + const { evictNamespace } = cacheClient const tasks = orgsToInvalidateRecords.flatMap(({ orgId, tenantCode: tCode }) => [organization.name, profile.name].map((ns) => ({ orgId, tenantCode: tCode, ns, - promise: invalidateOrgNamespaceVersion({ tenantCode: tCode, orgId, ns }), + promise: evictNamespace({ tenantCode: tCode, orgId, ns }), })) ) @@ -696,14 +696,14 @@ module.exports = class OrganizationsHelper { }) ) const { organization, profile } = common.CACHE_CONFIG.namespaces - const { invalidateOrgNamespaceVersion } = cacheClient + const { evictNamespace } = cacheClient const tasks = orgsToInvalidateRecords.flatMap(({ orgId, tenantCode: tCode }) => [organization.name, profile.name].map((ns) => ({ orgId, tenantCode: tCode, ns, - promise: invalidateOrgNamespaceVersion({ tenantCode: tCode, orgId, ns }), + promise: evictNamespace({ tenantCode: tCode, orgId, ns }), })) ) diff --git a/src/services/tenant.js b/src/services/tenant.js index 04ea9dacd..871d8fd4b 100644 --- a/src/services/tenant.js +++ b/src/services/tenant.js @@ -440,7 +440,7 @@ module.exports = class tenantHelper { ) } await cacheClient - .invalidateTenantVersion({ tenantCode, ns: common.CACHE_CONFIG.namespaces.tenant.name }) + .evictTenantByPattern({ tenantCode, ns: common.CACHE_CONFIG.namespaces.tenant.name }) .catch(() => {}) return responses.successResponse({ @@ -524,9 +524,12 @@ module.exports = class tenantHelper { }) } await cacheClient - .invalidateTenantVersion({ tenantCode, ns: common.CACHE_CONFIG.namespaces.tenant.name }) + .evictNamespace({ + tenantCode, + ns: common.CACHE_CONFIG.namespaces.tenant.name, + patternSuffix: '*', + }) .catch(() => {}) - return responses.successResponse({ statusCode: httpStatusCode.accepted, message: 'TENANT_DOMAINS_ADDED_SUCCESSFULLY', @@ -640,7 +643,11 @@ module.exports = class tenantHelper { await Promise.all(domainRemovePromise) await cacheClient - .invalidateTenantVersion({ tenantCode, ns: common.CACHE_CONFIG.namespaces.tenant.name }) + .evictNamespace({ + tenantCode, + ns: common.CACHE_CONFIG.namespaces.tenant.name, + patternSuffix: '*', + }) .catch(() => {}) return responses.successResponse({ diff --git a/src/services/user-role.js b/src/services/user-role.js index b24480c68..06c86921e 100644 --- a/src/services/user-role.js +++ b/src/services/user-role.js @@ -92,7 +92,7 @@ module.exports = class userRoleHelper { } await cacheClient - .invalidateOrgNamespaceVersion({ + .evictNamespace({ tenantCode, orgId: userOrganizationCode, ns: common.CACHE_CONFIG.namespaces.organization.name, @@ -101,7 +101,7 @@ module.exports = class userRoleHelper { console.error(error) }) await cacheClient - .invalidateOrgNamespaceVersion({ + .evictNamespace({ tenantCode, orgId: userOrganizationCode, ns: common.CACHE_CONFIG.namespaces.profile.name, @@ -159,7 +159,7 @@ module.exports = class userRoleHelper { } await cacheClient - .invalidateOrgNamespaceVersion({ + .evictNamespace({ tenantCode, orgId: userOrganizationCode, ns: common.CACHE_CONFIG.namespaces.organization.name, @@ -168,7 +168,7 @@ module.exports = class userRoleHelper { console.error(error) }) await cacheClient - .invalidateOrgNamespaceVersion({ + .evictNamespace({ tenantCode, orgId: userOrganizationCode, ns: common.CACHE_CONFIG.namespaces.profile.name, diff --git a/src/services/user.js b/src/services/user.js index 475f0a859..a7c34bd0f 100644 --- a/src/services/user.js +++ b/src/services/user.js @@ -308,7 +308,7 @@ module.exports = class UserHelper { const filter = utils.isNumeric(id) ? { id, tenant_code: tenantCode } : { share_link: id } const ns = common.CACHE_CONFIG.namespaces.profile.name - const fullKey = await cacheClient.versionedKey({ + const fullKey = await cacheClient.buildKey({ tenantCode, orgId: organizationCode, ns, @@ -318,7 +318,11 @@ module.exports = class UserHelper { const userDetails = await cacheClient.get(fullKey) if (userDetails) { - if (userDetails.image) userDetails.image = await utils.getDownloadableUrl(userDetails.image) + if (userDetails.image) { + userDetails.image_cloud_path = userDetails.image + userDetails.image = await utils.getDownloadableUrl(userDetails.image) + } + return responses.successResponse({ statusCode: httpStatusCode.ok, message: 'PROFILE_FETCHED_SUCCESSFULLY', From 83997f9a0e640c8ec5ced614d59ad4073b27ef15 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 23 Sep 2025 15:28:23 +0530 Subject: [PATCH 31/36] refactor: simplify cache eviction calls in tenant helper methods --- src/services/tenant.js | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/src/services/tenant.js b/src/services/tenant.js index 871d8fd4b..86f99f01e 100644 --- a/src/services/tenant.js +++ b/src/services/tenant.js @@ -439,9 +439,7 @@ module.exports = class tenantHelper { tenantUpdateBody ) } - await cacheClient - .evictTenantByPattern({ tenantCode, ns: common.CACHE_CONFIG.namespaces.tenant.name }) - .catch(() => {}) + await cacheClient.evictTenantByPattern(tenantCode).catch(() => {}) return responses.successResponse({ statusCode: httpStatusCode.accepted, @@ -523,13 +521,9 @@ module.exports = class tenantHelper { result: {}, }) } - await cacheClient - .evictNamespace({ - tenantCode, - ns: common.CACHE_CONFIG.namespaces.tenant.name, - patternSuffix: '*', - }) - .catch(() => {}) + + await cacheClient.evictTenantByPattern(tenantCode).catch(() => {}) + return responses.successResponse({ statusCode: httpStatusCode.accepted, message: 'TENANT_DOMAINS_ADDED_SUCCESSFULLY', @@ -642,13 +636,8 @@ module.exports = class tenantHelper { }) await Promise.all(domainRemovePromise) - await cacheClient - .evictNamespace({ - tenantCode, - ns: common.CACHE_CONFIG.namespaces.tenant.name, - patternSuffix: '*', - }) - .catch(() => {}) + + await cacheClient.evictTenantByPattern(tenantCode).catch(() => {}) return responses.successResponse({ statusCode: httpStatusCode.accepted, From 34e3bbcd3e572fa184f0d73398f8606dfb5c155c Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 23 Sep 2025 15:30:16 +0530 Subject: [PATCH 32/36] refactor: replace cache invalidation with eviction method for organization namespaces --- src/services/admin.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/services/admin.js b/src/services/admin.js index ecc4ebe15..68a7ed4b5 100644 --- a/src/services/admin.js +++ b/src/services/admin.js @@ -707,9 +707,7 @@ module.exports = class AdminHelper { ] const results = await Promise.allSettled( - namespaces.map((ns) => - cacheClient.invalidateOrgNamespaceVersion({ tenantCode, orgId: organizationCode, ns }) - ) + namespaces.map((ns) => cacheClient.evictNamespace({ tenantCode, orgId: organizationCode, ns })) ) results.forEach((r, i) => { if (r.status === 'rejected') { From 3421a0488754d9569022bef95c1b0b1a658d8dd8 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 23 Sep 2025 15:44:21 +0530 Subject: [PATCH 33/36] refactor: update deleteOneEntityType to include tenantCode parameter --- src/database/queries/entityType.js | 3 ++- src/services/entityType.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/database/queries/entityType.js b/src/database/queries/entityType.js index 2476588e6..b6eb375f2 100644 --- a/src/database/queries/entityType.js +++ b/src/database/queries/entityType.js @@ -106,12 +106,13 @@ module.exports = class UserEntityData { } } - static async deleteOneEntityType(id, organizationCode) { + static async deleteOneEntityType(id, organizationCode, tenantCode) { try { return await EntityType.destroy({ where: { id: id, organization_code: organizationCode, + tenant_code: tenantCode, }, individualHooks: true, }) diff --git a/src/services/entityType.js b/src/services/entityType.js index f16a31969..2c758eba2 100644 --- a/src/services/entityType.js +++ b/src/services/entityType.js @@ -195,7 +195,7 @@ module.exports = class EntityHelper { static async delete(id, organizationCode, tenantCode) { try { - const deleteCount = await entityTypeQueries.deleteOneEntityType(id, organizationCode) + const deleteCount = await entityTypeQueries.deleteOneEntityType(id, organizationCode, tenantCode) if (deleteCount === 0) { return responses.failureResponse({ message: 'ENTITY_TYPE_NOT_FOUND', From 51f5d48d3e473a3093085bc0cd85857da9de1642 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 23 Sep 2025 15:50:42 +0530 Subject: [PATCH 34/36] refactor: update cache eviction calls to use evictNamespace method --- src/services/entities.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/entities.js b/src/services/entities.js index d6abcc094..0949c2a8f 100644 --- a/src/services/entities.js +++ b/src/services/entities.js @@ -9,13 +9,13 @@ const cacheClient = require('@generics/cacheHelper') module.exports = class EntityHelper { static async _invalidateEntityCaches({ tenantCode, organizationCode }) { try { - await cacheClient.evictOrgByPattern({ + await cacheClient.evictNamespace({ tenantCode, orgId: organizationCode, ns: common.CACHE_CONFIG.namespaces.entity_types.name, }) - await cacheClient.evictOrgByPattern({ + await cacheClient.evictNamespace({ tenantCode, orgId: organizationCode, ns: common.CACHE_CONFIG.namespaces.profile.name, From 0e4bcc6d18d6691aee22dfa8c30109947ede27da Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 23 Sep 2025 15:55:53 +0530 Subject: [PATCH 35/36] refactor: include tenantCode in organization update queries --- src/services/organization.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/services/organization.js b/src/services/organization.js index ac2b8ea75..de4d54d6a 100644 --- a/src/services/organization.js +++ b/src/services/organization.js @@ -20,6 +20,7 @@ const organizationDTO = require('@dtos/organizationDTO') const responses = require('@helpers/responses') const userOrgQueries = require('@database/queries/userOrganization') const cacheClient = require('@generics/cacheHelper') +const tenant = require('@validators/v1/tenant') module.exports = class OrganizationsHelper { /** @@ -184,7 +185,7 @@ module.exports = class OrganizationsHelper { if (bodyData.relatedOrgs) { delete bodyData.relatedOrgs } - const orgDetailsBeforeUpdate = await organizationQueries.findOne({ id: id }) + const orgDetailsBeforeUpdate = await organizationQueries.findOne({ id: id, tenant_code: tenantCode }) if (!orgDetailsBeforeUpdate) { return responses.failureResponse({ statusCode: httpStatusCode.not_acceptable, @@ -192,7 +193,10 @@ module.exports = class OrganizationsHelper { message: 'ORGANIZATION_NOT_FOUND', }) } - const orgDetails = await organizationQueries.update({ id: id }, bodyData, { returning: true, raw: true }) + const orgDetails = await organizationQueries.update({ id: id, tenant_code: tenantCode }, bodyData, { + returning: true, + raw: true, + }) await cacheClient .evictNamespace({ tenantCode, From 6d3e3746bec47dc338a9b34eb4f3e2763c37dd0e Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 23 Sep 2025 18:44:26 +0530 Subject: [PATCH 36/36] refactor: add organization_features namespace to cache configuration and implement cache eviction logic --- src/constants/common.js | 11 +++- src/services/organization-feature.js | 87 +++++++++++++++++++++------- 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/constants/common.js b/src/constants/common.js index a8b10d135..e0737662a 100644 --- a/src/constants/common.js +++ b/src/constants/common.js @@ -115,15 +115,22 @@ module.exports = { SEQUELIZE_UNIQUE_CONSTRAINT_ERROR_CODE: 'ER_DUP_ENTRY', CACHE_CONFIG: { enableCache: true, - enableTracking: true, shards: 32, - versionDefault: 0, + common: { + list: 'list', + }, namespaces: { profile: { name: 'profile', enabled: true, defaultTtl: 3600, useInternal: false }, entity_types: { name: 'entity_types', enabled: true, defaultTtl: 86400, useInternal: false }, tenant: { name: 'tenant', enabled: true, defaultTtl: 21600, useInternal: false }, branding: { name: 'branding', enabled: true, defaultTtl: 43200, useInternal: false }, organization: { name: 'organization', enabled: true, defaultTtl: 21600, useInternal: false }, + organization_features: { + name: 'organization_features', + enabled: true, + defaultTtl: 21600, + useInternal: false, + }, }, }, } diff --git a/src/services/organization-feature.js b/src/services/organization-feature.js index 53da178fe..9aa0b3a6e 100644 --- a/src/services/organization-feature.js +++ b/src/services/organization-feature.js @@ -12,8 +12,27 @@ const responses = require('@helpers/responses') const utilsHelper = require('@generics/utils') const { UniqueConstraintError } = require('sequelize') const common = require('@constants/common') +const cacheClient = require('@generics/cacheHelper') module.exports = class organizationFeatureHelper { + static async _invalidateOrganizationFeatureCache({ tenantCode, organizationCode }) { + try { + await cacheClient.evictNamespace({ + tenantCode, + orgId: organizationCode, + ns: common.CACHE_CONFIG.namespaces.organization_features.name, + }) + + if (process.env.DEFAULT_ORGANISATION_CODE === organizationCode) { + await cacheClient.evictTenantByPattern(tenantCode, { + patternSuffix: `org:*:${common.CACHE_CONFIG.namespaces.organization_features.name}:*`, + }) + } + } catch (err) { + console.error('Org features cache invalidation failed', err) + // Do not throw. Caching failure should not block main operation. + } + } /** * Validate organization features Req. * @method @@ -93,6 +112,11 @@ module.exports = class organizationFeatureHelper { // Create the new organization feature const createdOrgFeature = await organizationFeatureQueries.create(bodyData) + await this._invalidateOrganizationFeatureCache({ + tenantCode: tokenInformation.tenant_code, + organizationCode: tokenInformation.organization_code, + }) + return responses.successResponse({ statusCode: httpStatusCode.created, message: 'ORG_FEATURE_CREATED_SUCCESSFULLY', @@ -148,7 +172,10 @@ module.exports = class organizationFeatureHelper { responseCode: 'CLIENT_ERROR', }) } - + await this._invalidateOrganizationFeatureCache({ + tenantCode: tokenInformation.tenant_code, + organizationCode: tokenInformation.organization_code, + }) return responses.successResponse({ statusCode: httpStatusCode.ok, message: 'ORG_FEATURE_UPDATED_SUCCESSFULLY', @@ -176,7 +203,22 @@ module.exports = class organizationFeatureHelper { static async list(tenantCode, orgCode) { try { - let filter = { + const ns = common.CACHE_CONFIG.namespaces.organization_features.name + const id = common.CACHE_CONFIG.common.list + + // 1) try final response cache + const finalKey = await cacheClient.buildKey({ tenantCode, orgId: orgCode, ns, id }) + const cached = await cacheClient.get(finalKey) + if (cached) { + return responses.successResponse({ + statusCode: httpStatusCode.ok, + message: 'ORG_FEATURE_FETCHED', + result: cached?.result ?? [], + }) + } + + // 2) DB reads + const filter = { organization_code: orgCode, tenant_code: tenantCode, } @@ -186,7 +228,6 @@ module.exports = class organizationFeatureHelper { }, } - // Fetch features for default and current org in parallel const [defaultOrgFeatures, currentOrgFeatures] = await Promise.all([ organizationFeatureQueries.findAllOrganizationFeature( { ...filter, organization_code: process.env.DEFAULT_ORGANISATION_CODE }, @@ -195,37 +236,38 @@ module.exports = class organizationFeatureHelper { organizationFeatureQueries.findAllOrganizationFeature(filter, queryOptions), ]) - // 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 + // 3) merge + sort + post-process + const featureMap = new Map(defaultOrgFeatures.map((f) => [f.feature_code, f])) if (currentOrgFeatures?.length) { - currentOrgFeatures.forEach((feature) => { - featureMap.set(feature.feature_code, feature) - }) + currentOrgFeatures.forEach((f) => featureMap.set(f.feature_code, f)) } + const sortedFeatures = Array.from(featureMap.values()).sort((a, b) => a.display_order - b.display_order) - const organizationFeatures = Array.from(featureMap.values()) - - // Sort the organization features based on the display_order in ascending order - const sortedFeatures = organizationFeatures.sort((a, b) => a.display_order - b.display_order) - - // Process icons in parallel if (sortedFeatures?.length) { await Promise.all( sortedFeatures.map(async (feature) => { - if (feature.icon) { - feature.icon = await utilsHelper.getDownloadableUrl(feature.icon) - } + if (feature.icon) feature.icon = await utilsHelper.getDownloadableUrl(feature.icon) }) ) } - return responses.successResponse({ + // 4) always build success response + const response = responses.successResponse({ statusCode: httpStatusCode.ok, message: 'ORG_FEATURE_FETCHED', result: sortedFeatures ?? [], }) + + // 5) cache final result (store only payload, not wrapper) + await cacheClient.setScoped({ + tenantCode, + orgId: orgCode, + ns, + id, + value: { result: sortedFeatures ?? [] }, + }) + + return response } catch (error) { throw error } @@ -305,7 +347,10 @@ module.exports = class organizationFeatureHelper { responseCode: 'CLIENT_ERROR', }) } - + await this._invalidateOrganizationFeatureCache({ + tenantCode: tokenInformation.tenant_code, + organizationCode: tokenInformation.organization_code, + }) return responses.successResponse({ statusCode: httpStatusCode.ok, message: 'ORG_FEATURE_DELETED_SUCCESSFULLY',