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/constants/common.js b/src/constants/common.js index 54bc26da9..e0737662a 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,24 @@ module.exports = { SIGNEDUP_STATUS: 'SIGNEDUP', SEQUELIZE_UNIQUE_CONSTRAINT_ERROR: 'SequelizeUniqueConstraintError', SEQUELIZE_UNIQUE_CONSTRAINT_ERROR_CODE: 'ER_DUP_ENTRY', + CACHE_CONFIG: { + enableCache: true, + shards: 32, + 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/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/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/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) { 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/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/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/generics/cacheHelper.js b/src/generics/cacheHelper.js new file mode 100644 index 000000000..fb47aec28 --- /dev/null +++ b/src/generics/cacheHelper.js @@ -0,0 +1,300 @@ +// 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(':') +} + +/** 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 +} + +/** 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) + // build simple key (no version token) + const fullKey = + ns || id + ? 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 + + 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 + * 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 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 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 buildKey({ 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). + */ +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. + * + * - 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 { + 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 { + if (unlink && typeof redis.unlink === 'function') await redis.unlink(...keys) + else await redis.del(...keys) + } catch (e) { + 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, + buildKey, + + // Eviction (pattern based) + delScoped, + evictNamespace, + evictOrgByPattern, + evictTenantByPattern, + scanAndDelete, + + // Introspection + _internal: { + getRedisClient, + SHARDS, + BATCH, + ENABLE_CACHE, + CACHE_CONFIG, + }, +} 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, -} diff --git a/src/helpers/userHelper.js b/src/helpers/userHelper.js index d3c160988..a49b2ccbe 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') @@ -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 } @@ -77,7 +85,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 +98,22 @@ 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 + for (const orgId of orgCodes) { + const fullKey = await cacheClient.buildKey({ + tenantCode: user.tenant_code, + orgId, + ns, + id: userId, + }) + await cacheClient.del(fullKey) + } + } catch (err) { + console.error('Failed to delete user cache', err) + } }, transactionOptions ) 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", 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", diff --git a/src/services/account.js b/src/services/account.js index 724479616..696910e84 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 @@ -557,6 +557,12 @@ module.exports = class AccountHelper { org_admin: orgAdmins, } ) + await cacheClient.evictNamespace({ + tenantCode: tenantDetail.code, + orgId: organization.code, + ns: common.CACHE_CONFIG.namespaces.organization.name, + patternSuffix: '*', + }) } const result = { access_token: accessToken, refresh_token: refreshToken, user } @@ -636,43 +642,87 @@ 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 }) + 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 +733,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,16 +741,20 @@ 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 + let user = userInstance ? (userInstance.toJSON ? userInstance.toJSON() : userInstance) : null if (!user) { return responses.failureResponse({ @@ -710,7 +764,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 +786,7 @@ module.exports = class AccountHelper { }) } - // Create user session + // Create session const userSessionDetails = await userSessionsService.createUserSession( user.id, '', @@ -741,16 +795,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 +804,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 +821,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 +835,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 +2040,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: organizationCode, + ns, + id: userId, + }) + await cacheClient.del(cacheKey) // remove reset otp caching await utils.redisDel(user?.username) diff --git a/src/services/admin.js b/src/services/admin.js index 21a44139c..68a7ed4b5 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,21 @@ module.exports = class AdminHelper { { attributes: { exclude: ['created_at', 'updated_at', 'deleted_at'] } } ) + await cacheClient.evictNamespace({ + tenantCode, + orgId: organization.code, + ns: common.CACHE_CONFIG.namespaces.organization.name, + patternSuffix: '*', + }) + + const cacheKey = cacheClient.namespacedKey({ + tenantCode, + orgId: organization.code, + ns: common.CACHE_CONFIG.namespaces.profile.name, + id: user.id, + }) + await cacheClient.del(cacheKey) + // Broadcast event asynchronously setImmediate(() => eventBroadcaster('updateOrganization', { @@ -682,9 +698,23 @@ 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 = [ + common.CACHE_CONFIG.namespaces.organization.name, + common.CACHE_CONFIG.namespaces.profile.name, + ] + + const results = await Promise.allSettled( + namespaces.map((ns) => cacheClient.evictNamespace({ tenantCode, orgId: organizationCode, ns })) + ) + 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) { const userIds = updatedUsers.map((u) => u.id) diff --git a/src/services/entities.js b/src/services/entities.js index f517d4af1..0949c2a8f 100644 --- a/src/services/entities.js +++ b/src/services/entities.js @@ -1,11 +1,40 @@ // 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.evictNamespace({ + tenantCode, + orgId: organizationCode, + ns: common.CACHE_CONFIG.namespaces.entity_types.name, + }) + + await cacheClient.evictNamespace({ + tenantCode, + orgId: organizationCode, + ns: common.CACHE_CONFIG.namespaces.profile.name, + }) + + if (process.env.DEFAULT_ORGANISATION_CODE === organizationCode) { + 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) { + console.error('Entity cache invalidation failed', err) + // Do not throw. Cache failures should not block DB ops. + } + } + /** * Create entity. * @method @@ -14,7 +43,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 +52,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 +90,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 +111,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 +139,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 +182,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 +194,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', diff --git a/src/services/entityType.js b/src/services/entityType.js index 955685af5..2c758eba2 100644 --- a/src/services/entityType.js +++ b/src/services/entityType.js @@ -1,21 +1,42 @@ -// DependenciesI +// EntityHelper.js +// 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.evictNamespace({ + tenantCode, + orgId: organizationCode, + ns: common.CACHE_CONFIG.namespaces.entity_types.name, + }) + + await cacheClient.evictNamespace({ + tenantCode, + orgId: organizationCode, + ns: common.CACHE_CONFIG.namespaces.profile.name, + }) + + if (process.env.DEFAULT_ORGANISATION_CODE === organizationCode) { + 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) { + 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 +46,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 +67,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 +89,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', @@ -91,26 +109,41 @@ module.exports = class EntityHelper { } } + // Read all system entity types (cached) static async readAllSystemEntityTypes(organizationCode, tenantCode, organizationId) { try { - 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', @@ -121,44 +154,48 @@ module.exports = class EntityHelper { } } + // Read user entity types by value (cached) 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 - 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) - - // Deduplicate entity types by value, prioritizing orgId - 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) 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) + const deleteCount = await entityTypeQueries.deleteOneEntityType(id, organizationCode, tenantCode) if (deleteCount === 0) { return responses.failureResponse({ message: 'ENTITY_TYPE_NOT_FOUND', @@ -167,6 +204,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', 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', diff --git a/src/services/organization.js b/src/services/organization.js index c83a854b0..de4d54d6a 100644 --- a/src/services/organization.js +++ b/src/services/organization.js @@ -19,6 +19,8 @@ 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') +const tenant = require('@validators/v1/tenant') module.exports = class OrganizationsHelper { /** @@ -177,13 +179,13 @@ 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) { 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, @@ -191,8 +193,28 @@ 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, + orgId: orgDetailsBeforeUpdate.code, + ns: common.CACHE_CONFIG.namespaces.organization.name, + }) + .catch((error) => { + console.error(error) + }) + await cacheClient + .evictNamespace({ + 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 +523,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 +534,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 +581,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 { evictNamespace } = cacheClient + + const tasks = orgsToInvalidateRecords.flatMap(({ orgId, tenantCode: tCode }) => + [organization.name, profile.name].map((ns) => ({ + orgId, + tenantCode: tCode, + ns, + promise: evictNamespace({ 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 +633,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 +649,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) + // ensure the orgs to remove exist in same tenant + const relatedOrgRecords = await organizationQueries.findAll( + { id: relatedOrgs, tenant_code: tenantCode }, + { raw: true } ) - - if (relatedOrgMismatchFlag) { + 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 { evictNamespace } = cacheClient + + const tasks = orgsToInvalidateRecords.flatMap(({ orgId, tenantCode: tCode }) => + [organization.name, profile.name].map((ns) => ({ + orgId, + tenantCode: tCode, + ns, + promise: evictNamespace({ 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, diff --git a/src/services/public.js b/src/services/public.js index 2a6a9f135..11b4c5663 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,20 +33,33 @@ 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 + orgId: organizationCode, // 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({ - statusCode: httpStatusCode.created, + statusCode: httpStatusCode.ok, message: 'TENANT_DETAILS', result: tenantTransformDTO.publicTransform({ tenant: tenantDetail, @@ -77,10 +91,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') diff --git a/src/services/tenant.js b/src/services/tenant.js index 6562394a9..86f99f01e 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.evictTenantByPattern(tenantCode).catch(() => {}) return responses.successResponse({ statusCode: httpStatusCode.accepted, @@ -520,6 +522,8 @@ module.exports = class tenantHelper { }) } + await cacheClient.evictTenantByPattern(tenantCode).catch(() => {}) + return responses.successResponse({ statusCode: httpStatusCode.accepted, message: 'TENANT_DOMAINS_ADDED_SUCCESSFULLY', @@ -633,6 +637,8 @@ module.exports = class tenantHelper { await Promise.all(domainRemovePromise) + await cacheClient.evictTenantByPattern(tenantCode).catch(() => {}) + return responses.successResponse({ statusCode: httpStatusCode.accepted, message: 'TENANT_DOMAINS_ADDED_SUCCESSFULLY', diff --git a/src/services/user-role.js b/src/services/user-role.js index 12032da20..06c86921e 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 + .evictNamespace({ + tenantCode, + orgId: userOrganizationCode, + ns: common.CACHE_CONFIG.namespaces.organization.name, + }) + .catch((error) => { + console.error(error) + }) + await cacheClient + .evictNamespace({ + 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,25 @@ module.exports = class userRoleHelper { }) } + await cacheClient + .evictNamespace({ + tenantCode, + orgId: userOrganizationCode, + ns: common.CACHE_CONFIG.namespaces.organization.name, + }) + .catch((error) => { + console.error(error) + }) + await cacheClient + .evictNamespace({ + tenantCode, + orgId: userOrganizationCode, + ns: common.CACHE_CONFIG.namespaces.profile.name, + }) + .catch((error) => { + console.error(error) + }) + return responses.successResponse({ statusCode: httpStatusCode.accepted, message: 'ROLE_DELETED_SUCCESSFULLY', @@ -200,7 +241,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) => { diff --git a/src/services/user.js b/src/services/user.js index c5663fa2f..a7c34bd0f 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,121 @@ 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', - }) - } + const filter = utils.isNumeric(id) ? { id, tenant_code: tenantCode } : { share_link: id } + + const ns = common.CACHE_CONFIG.namespaces.profile.name + const fullKey = await cacheClient.buildKey({ + tenantCode, + orgId: organizationCode, + ns, + id, + }) - let roles = user.organizations[0].roles + const userDetails = await cacheClient.get(fullKey) - 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 - }) + if (userDetails) { + if (userDetails.image) { + userDetails.image_cloud_path = userDetails.image + userDetails.image = await utils.getDownloadableUrl(userDetails.image) } - //user.user_roles = roles + 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 +587,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: organizationCode, + ns, + id, + }) + await cacheClient.del(cacheKey) + const processDbResponse = await utils.processDbResponse( JSON.parse(JSON.stringify(updatedData[0])), dataValidation