From 4c44ade5f2c417ebd2b22fdc36b2c880ccd8102b Mon Sep 17 00:00:00 2001 From: borkarsaish65 Date: Fri, 22 May 2026 18:19:27 +0530 Subject: [PATCH 1/5] revert: restore docker-image.yml to master state Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/docker-image.yml | 93 ++++++++---------------------- 1 file changed, 23 insertions(+), 70 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index d626ac75e..1f18816fa 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,4 +1,4 @@ -name: Build, Tag, and Push Docker Image +name: Build and Push Docker Image on: workflow_dispatch: @@ -6,28 +6,20 @@ on: tag_version: description: 'Docker image tag version (e.g., 3.3.11)' required: true - branch: - description: 'Branch to build from (default: staging)' - required: false - default: 'staging' env: - DOCKER_IMAGE_NAME: elevate-mentoring + DOCKER_IMAGE_NAME: elevate-mentoring # Configure your image name here DOCKER_REGISTRY: docker.io DOCKER_NAMESPACE: shikshalokamqa -permissions: - contents: write - jobs: docker-image-build-and-push: + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - - name: Checkout code from target branch + - name: Checkout code uses: actions/checkout@v4 - with: - ref: ${{ github.event.inputs.branch || 'staging' }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -38,64 +30,31 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Get Docker tag version + - name: Get Docker tag version (fail if not provided) id: get-version - shell: bash run: | - VERSION="${{ github.event.inputs.tag_version }}" - # Strip leading "v" if present - VERSION="${VERSION#v}" - # Enforce x.y.z or x.y.z-rc.1 format - if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then - echo "Error: tag_version must look like 3.4.0 or 3.4.0-rc.1 (no other suffixes allowed)" - exit 1 - fi - printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT" - - - name: Check if Git tag already exists - run: | - VERSION="${{ steps.get-version.outputs.version }}" - git fetch --tags - if git rev-parse "$VERSION" >/dev/null 2>&1; then - echo "Error: Git tag $VERSION already exists" + # Use workflow_dispatch input if provided + if [ ! -z "${{ github.event.inputs.tag_version }}" ]; then + VERSION="${{ github.event.inputs.tag_version }}" + # Or use TAG_VERSION env if set (for push/PR) + elif [ ! -z "${TAG_VERSION}" ]; then + VERSION="${TAG_VERSION}" + else + echo "Error: Docker image version must be provided as workflow_dispatch input or TAG_VERSION env." exit 1 fi + echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Check if version exists on Docker Hub + id: check-version run: | - VERSION="${{ steps.get-version.outputs.version }}" - - LOGIN_RESPONSE=$(curl -s -w "\n%{http_code}" \ - -X POST \ - -H "Content-Type: application/json" \ - -d '{"username": "${{ secrets.DOCKERHUB_USERNAME }}", "password": "${{ secrets.DOCKERHUB_TOKEN }}"}' \ - "https://hub.docker.com/v2/users/login") - LOGIN_HTTP=$(echo "$LOGIN_RESPONSE" | tail -n1) - LOGIN_BODY=$(echo "$LOGIN_RESPONSE" | head -n-1) - - if [ "$LOGIN_HTTP" -ne 200 ]; then - echo "Error: Docker Hub login failed with HTTP $LOGIN_HTTP" - exit 1 - fi - - TOKEN=$(echo "$LOGIN_BODY" | jq -r .token) - if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then - echo "Error: Docker Hub login succeeded but returned no token" - exit 1 - fi - - RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ - -H "Authorization: Bearer $TOKEN" \ - "https://hub.docker.com/v2/namespaces/${{ env.DOCKER_NAMESPACE }}/repositories/${{ env.DOCKER_IMAGE_NAME }}/tags/$VERSION") - + VERSION=${{ steps.get-version.outputs.version }} + RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "https://hub.docker.com/v2/namespaces/${{ env.DOCKER_NAMESPACE }}/repositories/${{ env.DOCKER_IMAGE_NAME }}/tags/$VERSION") if [ "$RESPONSE" -eq 200 ]; then echo "Error: Tag $VERSION already exists on Docker Hub" exit 1 - elif [ "$RESPONSE" -eq 404 ]; then - echo "Tag $VERSION not found on Docker Hub — safe to push" else - echo "Error: Unexpected HTTP $RESPONSE from Docker Hub tag check; aborting to fail safe" - exit 1 + echo "Tag $VERSION does not exist, proceeding with build" fi - name: Extract metadata @@ -119,16 +78,10 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: Create and push Git tag - run: | - VERSION="${{ steps.get-version.outputs.version }}" - git config user.name "github-actions" - git config user.email "github-actions@github.com" - git tag -a "$VERSION" -m "Release $VERSION" - git push origin "$VERSION" + - name: Image digest + run: echo "Image pushed with digest ${{ steps.build.outputs.digest }}" - - name: Job summary + - name: Print pushed tags run: | - echo "### Docker Image Published 🚀" >> $GITHUB_STEP_SUMMARY - echo "**Tag:** ${{ steps.get-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "**Digest:** ${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY + echo "Pushed tags:" + echo "${{ steps.meta.outputs.tags }}" | tr ',' '\n' \ No newline at end of file From e58983492af8b653174c66a10efe714a594cbf55 Mon Sep 17 00:00:00 2001 From: borkarsaish65 Date: Wed, 27 May 2026 14:26:27 +0530 Subject: [PATCH 2/5] savepoint-1 --- src/constants/common.js | 6 + src/controllers/v1/org-admin.js | 11 +- ...60525000001-backfill-org-codes-from-ids.js | 157 ++++++++++++++++++ ...60525000002-rename-visible-to-org-codes.js | 61 +++++++ src/database/models/sessions.js | 4 + src/database/queries/mentorExtension.js | 9 +- src/database/queries/sessions.js | 4 +- src/database/queries/userExtension.js | 13 +- src/generics/cacheHelper.js | 50 +++--- src/generics/kafka/consumers/organization.js | 27 ++- ...dAndEntityTypewithEntitiesBasedOnPolicy.js | 2 +- src/helpers/orgUtils.js | 67 ++++++++ src/helpers/saasUserAccessibility.js | 28 ++-- src/scripts/dropOrgNumericBackupColumns.js | 143 ++++++++++++++++ src/services/mentees.js | 66 ++++---- src/services/mentors.js | 50 +++--- src/services/org-admin.js | 44 +++-- src/services/sessions.js | 55 +++--- 18 files changed, 622 insertions(+), 175 deletions(-) create mode 100644 src/database/migrations/20260525000001-backfill-org-codes-from-ids.js create mode 100644 src/database/migrations/20260525000002-rename-visible-to-org-codes.js create mode 100644 src/helpers/orgUtils.js create mode 100644 src/scripts/dropOrgNumericBackupColumns.js diff --git a/src/constants/common.js b/src/constants/common.js index 3f92659ca..e967b5a77 100644 --- a/src/constants/common.js +++ b/src/constants/common.js @@ -310,6 +310,12 @@ module.exports = { defaultTtl: 86400, // 1 day useInternal: false, }, + orgIdCode: { + name: 'orgIdCode', + enabled: true, + defaultTtl: 86400, // 1 day + useInternal: false, + }, }, }, diff --git a/src/controllers/v1/org-admin.js b/src/controllers/v1/org-admin.js index 924668da2..e7c57bcc8 100644 --- a/src/controllers/v1/org-admin.js +++ b/src/controllers/v1/org-admin.js @@ -1,5 +1,6 @@ const orgAdminService = require('@services/org-admin') const common = require('@constants/common') +const { convertOrgIdsToOrgCodes } = require('@helpers/orgUtils') module.exports = class OrgAdmin { /** @@ -135,12 +136,10 @@ module.exports = class OrgAdmin { */ async updateRelatedOrgs(req) { try { - return await orgAdminService.updateRelatedOrgs( - req.body.delta_organization_ids, - req.body.organization_id, - req.body.action, - req.body.tenant_code - ) + const tenantCode = req.body.tenant_code + const deltaOrgCodes = await convertOrgIdsToOrgCodes(req.body.delta_organization_ids || [], tenantCode) + const [orgCode] = await convertOrgIdsToOrgCodes([req.body.organization_id], tenantCode) + return await orgAdminService.updateRelatedOrgs(deltaOrgCodes, orgCode, req.body.action, tenantCode) } catch (error) { return error } diff --git a/src/database/migrations/20260525000001-backfill-org-codes-from-ids.js b/src/database/migrations/20260525000001-backfill-org-codes-from-ids.js new file mode 100644 index 000000000..3c368b452 --- /dev/null +++ b/src/database/migrations/20260525000001-backfill-org-codes-from-ids.js @@ -0,0 +1,157 @@ +'use strict' + +/** + * PR 1 — Migrations gate for Issue 3 (organization_id → organization_code deprecation). + * + * Adds mentor_organization_code to sessions and backfills visible_to_organizations + * arrays in sessions + user_extensions from numeric IDs to org code strings. + * + * Strategy: load the full org lookup into JS, map in JS, write back in batches. + * No complex SQL joins — easy to read and debug. + * + * IMPORTANT: Deploy and verify this migration BEFORE deploying the PR 2 code changes. + */ +module.exports = { + async up(queryInterface, Sequelize) { + // ── 0. Add the new column (safe to run even if it already exists) ────── + const [existing] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'mentor_organization_code' + `) + if (existing.length === 0) { + await queryInterface.addColumn('sessions', 'mentor_organization_code', { + type: Sequelize.STRING, + allowNull: true, + }) + console.log('Added column: sessions.mentor_organization_code') + } + + // ── 1. Build org ID → code lookup (one query, shared by all steps) ──── + const [orgRows] = await queryInterface.sequelize.query(` + SELECT organization_id, organization_code, tenant_code + FROM organization_extension + `) + + // Primary key: "tenantCode:orgId" → orgCode + const lookup = new Map(orgRows.map((r) => [`${r.tenant_code}:${r.organization_id}`, r.organization_code])) + + // Fallback: orgId alone → orgCode, for rows where the session's tenant_code does not + // match the tenant_code on the organization_extension row for the same numeric org ID. + // Last-write wins when the same org_id appears under multiple tenants — acceptable + // for backfill because org codes are stable and unique per org across tenants. + const fallbackLookup = new Map(orgRows.map((r) => [String(r.organization_id), r.organization_code])) + + const toCode = (id, tenantCode) => + lookup.get(`${tenantCode}:${String(id)}`) ?? fallbackLookup.get(String(id)) ?? null + + // Convert each element of a visible_to_organizations array. + // Unknown IDs are left as-is so data is never silently lost. + const toCodeArray = (arr, tenantCode) => arr.map((id) => toCode(id, tenantCode) ?? id) + + // Format a JS string array as a Postgres array literal: {val1,val2} + const pgArr = (arr) => `{${arr.join(',')}}` + + // ── Step 1: sessions.mentor_organization_code ───────────────────────── + console.log('\nStep 1: Backfilling sessions.mentor_organization_code ...') + + const [sessions] = await queryInterface.sequelize.query(` + SELECT id, mentor_organization_id, tenant_code + FROM sessions + `) + + const sessionCodeUpdates = sessions + .map((s) => ({ id: s.id, code: toCode(s.mentor_organization_id, s.tenant_code) })) + .filter((u) => u.code !== null) + + if (sessionCodeUpdates.length) { + // Batch all updates in one VALUES-based UPDATE — no per-row round trips + const values = sessionCodeUpdates.map((u) => `(${u.id}, '${u.code}')`).join(', ') + await queryInterface.sequelize.query(` + UPDATE sessions + SET mentor_organization_code = v.code + FROM (VALUES ${values}) AS v(id, code) + WHERE sessions.id = v.id::int + `) + } + + console.log(` Updated ${sessionCodeUpdates.length} / ${sessions.length} rows`) + + // ── Step 2: sessions.visible_to_organization_codes (new column) ────── + console.log('\nStep 2: Adding and backfilling sessions.visible_to_organization_codes ...') + + const [existingVtoSessions] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'visible_to_organization_codes' + `) + if (existingVtoSessions.length === 0) { + await queryInterface.addColumn('sessions', 'visible_to_organization_codes', { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: true, + }) + console.log(' Added column: sessions.visible_to_organization_codes') + } + + const [sessionRows] = await queryInterface.sequelize.query(` + SELECT id, visible_to_organizations, tenant_code + FROM sessions + WHERE visible_to_organizations IS NOT NULL + `) + + let sessionArrUpdated = 0 + for (const row of sessionRows) { + const newArr = toCodeArray(row.visible_to_organizations, row.tenant_code) + await queryInterface.sequelize.query( + 'UPDATE sessions SET visible_to_organization_codes = :arr WHERE id = :id', + { replacements: { arr: pgArr(newArr), id: row.id } } + ) + sessionArrUpdated++ + } + + console.log(` Updated ${sessionArrUpdated} rows`) + + // ── Step 3: user_extensions.visible_to_organization_codes ───────────── + console.log('\nStep 3: Adding and backfilling user_extensions.visible_to_organization_codes ...') + + const [existingVtoUE] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = 'user_extensions' AND column_name = 'visible_to_organization_codes' + `) + if (existingVtoUE.length === 0) { + await queryInterface.addColumn('user_extensions', 'visible_to_organization_codes', { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: true, + }) + console.log(' Added column: user_extensions.visible_to_organization_codes') + } + + const [ueRows] = await queryInterface.sequelize.query(` + SELECT user_id, visible_to_organizations, tenant_code + FROM user_extensions + WHERE visible_to_organizations IS NOT NULL + `) + + let ueUpdated = 0 + for (const row of ueRows) { + const newArr = toCodeArray(row.visible_to_organizations, row.tenant_code) + await queryInterface.sequelize.query( + `UPDATE user_extensions + SET visible_to_organization_codes = :arr + WHERE user_id = :userId + AND tenant_code = :tenantCode`, + { replacements: { arr: pgArr(newArr), userId: row.user_id, tenantCode: row.tenant_code } } + ) + ueUpdated++ + } + + console.log(` Updated ${ueUpdated} rows`) + }, + + async down(queryInterface) { + // All new columns are dropped. Original columns (mentor_organization_id, + // visible_to_organizations) are untouched so data is fully preserved. + await queryInterface.removeColumn('sessions', 'mentor_organization_code') + await queryInterface.removeColumn('sessions', 'visible_to_organization_codes') + await queryInterface.removeColumn('user_extensions', 'visible_to_organization_codes') + console.log('Removed new columns. Original data in existing columns is untouched.') + }, +} diff --git a/src/database/migrations/20260525000002-rename-visible-to-org-codes.js b/src/database/migrations/20260525000002-rename-visible-to-org-codes.js new file mode 100644 index 000000000..497a106bd --- /dev/null +++ b/src/database/migrations/20260525000002-rename-visible-to-org-codes.js @@ -0,0 +1,61 @@ +'use strict' + +/** + * PR 2 — Rename migration for Issue 3 (organization_id → organization_code deprecation). + * + * Renames the old visible_to_organizations columns (numeric IDs) to + * visible_to_organizations_numeric as a backup, then promotes visible_to_organization_codes + * (org code strings) to take the visible_to_organizations name. + * + * This keeps the original column name in the final schema (no code or model changes needed) + * and preserves the numeric backup so down() is fully reversible. + * + * IMPORTANT: Run AFTER verifying PR 1 migration data side-by-side. + * Deploy this migration together with the PR 2 code changes. + * The _numeric backup columns can be dropped in a follow-up cleanup migration once verified. + */ +module.exports = { + async up(queryInterface) { + // sessions: park old column as backup, promote new column to primary name + await queryInterface.renameColumn('sessions', 'visible_to_organizations', 'visible_to_organizations_numeric') + await queryInterface.renameColumn('sessions', 'visible_to_organization_codes', 'visible_to_organizations') + console.log( + 'sessions: visible_to_organizations (numeric) → visible_to_organizations_numeric (backup); visible_to_organization_codes → visible_to_organizations' + ) + + // user_extensions: same swap + await queryInterface.renameColumn( + 'user_extensions', + 'visible_to_organizations', + 'visible_to_organizations_numeric' + ) + await queryInterface.renameColumn( + 'user_extensions', + 'visible_to_organization_codes', + 'visible_to_organizations' + ) + console.log( + 'user_extensions: visible_to_organizations (numeric) → visible_to_organizations_numeric (backup); visible_to_organization_codes → visible_to_organizations' + ) + }, + + async down(queryInterface) { + // Fully reversible: rename both columns back to their pre-up names. + // Numeric ID data is preserved in visible_to_organizations_numeric throughout. + await queryInterface.renameColumn('sessions', 'visible_to_organizations', 'visible_to_organization_codes') + await queryInterface.renameColumn('sessions', 'visible_to_organizations_numeric', 'visible_to_organizations') + + await queryInterface.renameColumn( + 'user_extensions', + 'visible_to_organizations', + 'visible_to_organization_codes' + ) + await queryInterface.renameColumn( + 'user_extensions', + 'visible_to_organizations_numeric', + 'visible_to_organizations' + ) + + console.log('Reverted rename. Numeric ID data fully restored in visible_to_organizations.') + }, +} diff --git a/src/database/models/sessions.js b/src/database/models/sessions.js index 2cff86852..caf5f1e66 100644 --- a/src/database/models/sessions.js +++ b/src/database/models/sessions.js @@ -114,6 +114,10 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING, allowNull: false, }, + mentor_organization_code: { + type: DataTypes.STRING, + allowNull: true, + }, seats_remaining: { type: DataTypes.INTEGER, defaultValue: process.env.SESSION_MENTEE_LIMIT, diff --git a/src/database/queries/mentorExtension.js b/src/database/queries/mentorExtension.js index 34036688f..e741962e3 100644 --- a/src/database/queries/mentorExtension.js +++ b/src/database/queries/mentorExtension.js @@ -357,7 +357,9 @@ module.exports = class MentorExtensionQueries { await MentorExtension.update( { visible_to_organizations: sequelize.literal( - `array_cat("visible_to_organizations", ARRAY[${newRelatedOrgs}]::integer[])` + `array_cat(COALESCE("visible_to_organizations", ARRAY[]::varchar[]), ARRAY[${newRelatedOrgs + .map((v) => `'${v}'`) + .join(',')}]::varchar[])` ), }, { @@ -384,8 +386,9 @@ module.exports = class MentorExtensionQueries { ) return await MentorExtension.update( { - visible_to_organizations: sequelize.literal(`COALESCE("visible_to_organizations", - ARRAY[]::integer[]) || ARRAY[${organizationId}]::integer[]`), + visible_to_organizations: sequelize.literal( + `COALESCE("visible_to_organizations", ARRAY[]::varchar[]) || ARRAY['${organizationId}']::varchar[]` + ), }, { where: { diff --git a/src/database/queries/sessions.js b/src/database/queries/sessions.js index b589a65b4..e43c28a5f 100644 --- a/src/database/queries/sessions.js +++ b/src/database/queries/sessions.js @@ -284,7 +284,7 @@ exports.getSessionTenantCode = async (sessionId) => { try { return await Session.findOne({ where: { id: sessionId }, - attributes: ['id', 'tenant_code', 'mentor_organization_id'], + attributes: ['id', 'tenant_code', 'mentor_organization_id', 'mentor_organization_code'], raw: true, }) } catch (error) { @@ -762,6 +762,7 @@ exports.getUpcomingSessionsFromView = async ( 'mentor_id', 'visibility', 'mentor_organization_id', + 'mentor_organization_code', 'created_at', 'mentor_name', "(meeting_info - 'link') AS meeting_info", @@ -904,6 +905,7 @@ exports.getMentorsUpcomingSessionsFromView = async ( Sessions.meeting_info, Sessions.visibility, Sessions.mentor_organization_id, + Sessions.mentor_organization_code, Sessions.type, CASE WHEN sa.id IS NOT NULL THEN true ELSE false END AS is_enrolled, COALESCE(sa.type, NULL) AS enrolment_type diff --git a/src/database/queries/userExtension.js b/src/database/queries/userExtension.js index 9082a455b..70ce1aee6 100644 --- a/src/database/queries/userExtension.js +++ b/src/database/queries/userExtension.js @@ -123,7 +123,7 @@ module.exports = class MenteeExtensionQueries { { where: { tenant_code: tenantCode, - organization_id: organizationId, + organization_code: organizationId, [Op.or]: [ { [Op.not]: { @@ -144,16 +144,16 @@ module.exports = class MenteeExtensionQueries { } ) - return await MenteeExtension.update( + const result = await MenteeExtension.update( { visible_to_organizations: sequelize.literal( - `COALESCE("visible_to_organizations", ARRAY[]::varchar[]) || ARRAY[${organizationId}]::varchar[]` + `COALESCE("visible_to_organizations", ARRAY[]::varchar[]) || ARRAY['${organizationId}']::varchar[]` ), }, { where: { tenant_code: tenantCode, - organization_id: { + organization_code: { [Op.in]: newRelatedOrgsArray, }, [Op.or]: [ @@ -175,6 +175,7 @@ module.exports = class MenteeExtensionQueries { ...otherOptions, } ) + return result } static async removeVisibleToOrg(orgId, elementsToRemove, tenantCode) { @@ -185,7 +186,7 @@ module.exports = class MenteeExtensionQueries { FROM unnest("visible_to_organizations") AS elem WHERE elem NOT IN (:elementsToRemove) ), '{}') - WHERE organization_id = :orgId AND tenant_code = :tenantCode + WHERE organization_code = :orgId AND tenant_code = :tenantCode ` await Sequelize.query(organizationUpdateQuery, { @@ -199,7 +200,7 @@ module.exports = class MenteeExtensionQueries { FROM unnest("visible_to_organizations") AS elem WHERE elem NOT IN (:orgId) ), '{}') - WHERE organization_id IN (:elementsToRemove) AND tenant_code = :tenantCode + WHERE organization_code IN (:elementsToRemove) AND tenant_code = :tenantCode ` await Sequelize.query(relatedOrganizationUpdateQuery, { diff --git a/src/generics/cacheHelper.js b/src/generics/cacheHelper.js index 1a7969621..2934c726f 100644 --- a/src/generics/cacheHelper.js +++ b/src/generics/cacheHelper.js @@ -696,31 +696,9 @@ const entityTypes = { * @param {string} modelName - Model name ('Session' or 'UserExtension') * @returns {Promise} Array of entity types */ - async getEntityTypesWithMentorOrg(tenantCode, currentOrgCode, mentorOrganizationId, modelName) { + async getEntityTypesWithMentorOrg(tenantCode, currentOrgCode, mentorOrganizationCode, modelName) { try { - // Step 1: Get mentor organization code using organization cache - let mentorOrgCode = null - if (mentorOrganizationId) { - try { - const mentorOrg = await organizations.get(tenantCode, currentOrgCode, mentorOrganizationId) - mentorOrgCode = mentorOrg?.organization_code - } catch (orgCacheError) { - console.warn('Organization cache lookup failed, falling back to database query') - // Fallback: Direct database query for organization code - const organisationExtensionQueries = require('@database/queries/organisationExtension') - const orgData = await organisationExtensionQueries.findOne( - { organization_id: mentorOrganizationId }, - tenantCode, - { attributes: ['organization_code'], raw: true } - ) - mentorOrgCode = orgData?.organization_code - } - } - - // Step 2: Use mentor org code if available, otherwise current org code - const effectiveOrgCode = mentorOrgCode || currentOrgCode - - // Step 3: Get entity types for the specific model and org + const effectiveOrgCode = mentorOrganizationCode || currentOrgCode return await this.getAllEntityTypesForModel(tenantCode, effectiveOrgCode, modelName) } catch (error) { console.error('Failed to get entity types with mentor org resolution:', error) @@ -904,6 +882,29 @@ const organizations = { }, } +/** + * Org ID → Org Code Cache Helpers + * Pattern: tenant:${tenantCode}:orgIdCode:${orgId} + * TTL: 1 day — Kafka consumer invalidates individual entries on org create/update/deactivate + */ +const orgIdCode = { + async get(tenantCode, orgId) { + const cacheKey = await buildKey({ tenantCode, ns: 'orgIdCode', id: String(orgId) }) + const cached = await get(cacheKey, { useInternal: nsUseInternal('orgIdCode') }) + return cached?.code ?? null + }, + + async set(tenantCode, orgId, code) { + const cacheKey = await buildKey({ tenantCode, ns: 'orgIdCode', id: String(orgId) }) + return set(cacheKey, { code }, nsTtl('orgIdCode'), { useInternal: nsUseInternal('orgIdCode') }) + }, + + async delete(tenantCode, orgId) { + const cacheKey = await buildKey({ tenantCode, ns: 'orgIdCode', id: String(orgId) }) + return del(cacheKey, { useInternal: nsUseInternal('orgIdCode') }) + }, +} + /** * Mentor Profile Cache Helpers * Pattern: tenant:${tenantCode}:org:${orgCode}:mentor:${id} @@ -1800,6 +1801,7 @@ module.exports = { entityTypes, forms, organizations, + orgIdCode, mentor, mentee, platformConfig, diff --git a/src/generics/kafka/consumers/organization.js b/src/generics/kafka/consumers/organization.js index 0d2439c6f..2379b5f27 100644 --- a/src/generics/kafka/consumers/organization.js +++ b/src/generics/kafka/consumers/organization.js @@ -1,5 +1,7 @@ const orgService = require('@services/organization') const orgAdminService = require('@services/org-admin') +const { convertOrgIdsToOrgCodes } = require('@helpers/orgUtils') +const cacheHelper = require('@generics/cacheHelper') var messageReceived = function (message) { return new Promise(async function (resolve, reject) { @@ -37,6 +39,7 @@ var messageReceived = function (message) { created_by: created_by || 'system', } await orgService.createOrgExtension(createEventBody, tenant_code) + await cacheHelper.orgIdCode.delete(tenant_code, entityId) break case 'update': @@ -56,24 +59,18 @@ var messageReceived = function (message) { // Handle added related orgs if (addedOrgs.length > 0) { - const addedOrgIds = addedOrgs.map((id) => id.toString()) - await orgAdminService.updateRelatedOrgs( - addedOrgIds, - entityId.toString(), - 'push', // Add action - tenant_code - ) + const addedOrgCodes = await convertOrgIdsToOrgCodes(addedOrgs, tenant_code) + if (addedOrgCodes.length > 0) { + await orgAdminService.updateRelatedOrgs(addedOrgCodes, code, 'push', tenant_code) + } } // Handle removed related orgs if (removedOrgs.length > 0) { - const removedOrgIds = removedOrgs.map((id) => id.toString()) - await orgAdminService.updateRelatedOrgs( - removedOrgIds, - entityId.toString(), - 'pop', // Remove action - tenant_code - ) + const removedOrgCodes = await convertOrgIdsToOrgCodes(removedOrgs, tenant_code) + if (removedOrgCodes.length > 0) { + await orgAdminService.updateRelatedOrgs(removedOrgCodes, code, 'pop', tenant_code) + } } } @@ -88,6 +85,7 @@ var messageReceived = function (message) { } await orgService.createOrgExtension(updateEventBody, tenant_code) } + await cacheHelper.orgIdCode.delete(tenant_code, entityId) break case 'deactivate': @@ -102,6 +100,7 @@ var messageReceived = function (message) { updated_by: updated_by || 'system', } await orgService.createOrgExtension(deactivateEventBody, tenant_code) + await cacheHelper.orgIdCode.delete(tenant_code, entityId) break default: diff --git a/src/helpers/getOrgIdAndEntityTypewithEntitiesBasedOnPolicy.js b/src/helpers/getOrgIdAndEntityTypewithEntitiesBasedOnPolicy.js index 5cb1a6099..a99917b26 100644 --- a/src/helpers/getOrgIdAndEntityTypewithEntitiesBasedOnPolicy.js +++ b/src/helpers/getOrgIdAndEntityTypewithEntitiesBasedOnPolicy.js @@ -188,7 +188,7 @@ module.exports = class OrganizationAndEntityTypePolicyHelper { ], }, ], - organization_id: { [Op.ne]: orgExtension.organization_id }, + organization_code: { [Op.ne]: orgExtension.organization_code }, tenant_code: tenantCode, }, { diff --git a/src/helpers/orgUtils.js b/src/helpers/orgUtils.js new file mode 100644 index 000000000..5ace3b5ba --- /dev/null +++ b/src/helpers/orgUtils.js @@ -0,0 +1,67 @@ +'use strict' + +const { Op } = require('sequelize') +const organisationExtensionQueries = require('@database/queries/organisationExtension') +const cacheHelper = require('@generics/cacheHelper') + +/** + * Converts an array of numeric org IDs (as returned by the User Service) + * to their corresponding org codes using the local organisation_extensions table. + * + * Cache strategy: individual Redis key per org ID (tenant:${tenantCode}:orgIdCode:${orgId}). + * Only uncached IDs hit the DB. Kafka consumer invalidates entries on org create/update/deactivate. + * + * @param {Array} orgIds - Numeric org IDs e.g. [1, 2, 3] + * @param {string} tenantCode + * @returns {Promise} - Org codes e.g. ['DEFAULT', 'sunbird'] + */ +async function convertOrgIdsToOrgCodes(orgIds, tenantCode) { + if (!orgIds?.length) return [] + + const normalizedIds = [...new Set(orgIds.map(String))] + + // Check cache for all IDs in parallel + const cacheResults = await Promise.all(normalizedIds.map((id) => cacheHelper.orgIdCode.get(tenantCode, id))) + + const idToCode = {} + const uncachedIds = [] + + normalizedIds.forEach((id, i) => { + if (cacheResults[i] !== null && cacheResults[i] !== undefined) { + idToCode[id] = cacheResults[i] + } else { + uncachedIds.push(id) + } + }) + + // Fetch only uncached IDs from DB + if (uncachedIds.length > 0) { + const rows = await organisationExtensionQueries.findAll( + { + organization_id: { [Op.in]: uncachedIds }, + tenant_code: tenantCode, + }, + { attributes: ['organization_id', 'organization_code'] } + ) + + // Cache each fetched entry individually and build local map + await Promise.all( + rows.map((r) => { + const id = String(r.organization_id) + idToCode[id] = r.organization_code + return cacheHelper.orgIdCode.set(tenantCode, id, r.organization_code) + }) + ) + } + + const codes = orgIds.map((id) => idToCode[String(id)]).filter(Boolean) + + const missing = orgIds.filter((id) => !idToCode[String(id)]) + if (missing.length) { + console.warn(`convertOrgIdsToOrgCodes: no org_code found for IDs [${missing}] in tenant ${tenantCode}`) + } + + return codes +} + +module.exports = { convertOrgIdsToOrgCodes } diff --git a/src/helpers/saasUserAccessibility.js b/src/helpers/saasUserAccessibility.js index 90e6604ad..714b8ffb1 100644 --- a/src/helpers/saasUserAccessibility.js +++ b/src/helpers/saasUserAccessibility.js @@ -22,7 +22,7 @@ async function checkIfUserIsAccessible(userId, userData, tenantCode, orgCode) { (await cacheHelper.mentee.getCacheOnly(tenantCode, userId)) || (await menteeQueries.getMenteeExtension( userId, - ['external_mentor_visibility', 'external_mentee_visibility', 'organization_id'], + ['external_mentor_visibility', 'external_mentee_visibility', 'organization_id', 'organization_code'], false, tenantCode )) @@ -30,11 +30,11 @@ async function checkIfUserIsAccessible(userId, userData, tenantCode, orgCode) { return false // If no user policy details found, return false for accessibility } - const { organization_id, external_mentor_visibility, external_mentee_visibility } = userPolicyDetails + const { organization_code, external_mentor_visibility, external_mentee_visibility } = userPolicyDetails // Ensure data for accessibility evaluation - if (!organization_id) { - return false // If no organization_id is found, return false for accessibility + if (!organization_code) { + return false // If no organization_code is found, return false for accessibility } // For single user, return boolean indicating accessibility @@ -48,22 +48,22 @@ async function checkIfUserIsAccessible(userId, userData, tenantCode, orgCode) { switch (visibilityKey) { case common.CURRENT: - isAccessible = user.organization_id === organization_id + isAccessible = user.organization_code === organization_code break case common.ASSOCIATED: isAccessible = - (user.visible_to_organizations.includes(organization_id) && + (user.visible_to_organizations.includes(organization_code) && user[roleVisibilityKey] !== common.CURRENT) || - user.organization_id === organization_id + user.organization_code === organization_code break case common.ALL: isAccessible = - (user.visible_to_organizations.includes(organization_id) && + (user.visible_to_organizations.includes(organization_code) && user[roleVisibilityKey] !== common.CURRENT) || user[roleVisibilityKey] === common.ALL || - user.organization_id === organization_id + user.organization_code === organization_code break default: @@ -83,22 +83,22 @@ async function checkIfUserIsAccessible(userId, userData, tenantCode, orgCode) { switch (visibilityKey) { case common.CURRENT: - isAccessible = user.organization_id === organization_id + isAccessible = user.organization_code === organization_code break case common.ASSOCIATED: isAccessible = - (user.visible_to_organizations.includes(organization_id) && + (user.visible_to_organizations.includes(organization_code) && user[roleVisibilityKey] !== common.CURRENT) || - user.organization_id === organization_id + user.organization_code === organization_code break case common.ALL: isAccessible = - (user.visible_to_organizations.includes(organization_id) && + (user.visible_to_organizations.includes(organization_code) && user[roleVisibilityKey] !== common.CURRENT) || user[roleVisibilityKey] === common.ALL || - user.organization_id === organization_id + user.organization_code === organization_code break default: diff --git a/src/scripts/dropOrgNumericBackupColumns.js b/src/scripts/dropOrgNumericBackupColumns.js new file mode 100644 index 000000000..e39ffbdba --- /dev/null +++ b/src/scripts/dropOrgNumericBackupColumns.js @@ -0,0 +1,143 @@ +'use strict' + +/** + * Drop Numeric Backup Columns Script + * + * Drops the `visible_to_organizations_numeric` backup columns from `sessions` + * and `user_extensions` tables. These columns were created by migration + * 20260525000002-rename-visible-to-org-codes.js as a safety backup of the + * original numeric-ID data before the org-code migration went live. + * + * Run this ONLY after: + * 1. PR 2 (org-code migration) has been live in production for enough time + * to confirm data correctness in visible_to_organizations (now org codes). + * 2. You have verified no rollback is needed (migration down() will no longer + * work correctly after this script runs, as the numeric backup is gone). + * + * Uses CASCADE on DROP COLUMN to automatically drop dependent materialized views, + * then rebuilds them via checkAndCreateMaterializedViews(). + * + * Modes: + * --dry-run Print what would be dropped + row/null counts; no DB changes. + * --count Same as --dry-run: inspect counts only, no drops. + * (default) Drop the columns for real. + * + * Usage: + * node src/scripts/dropOrgNumericBackupColumns.js + * node src/scripts/dropOrgNumericBackupColumns.js --dry-run + * node src/scripts/dropOrgNumericBackupColumns.js --count + */ + +require('module-alias/register') +require('dotenv').config({ path: `${__dirname}/../.env` }) + +const db = require('@database/models/index') +const materializedViewsService = require('@generics/materializedViews') + +const BACKUP_COLUMN = 'visible_to_organizations_numeric' + +const TARGETS = [ + { table: 'sessions', column: BACKUP_COLUMN }, + { table: 'user_extensions', column: BACKUP_COLUMN }, +] + +const isDryRun = process.argv.includes('--dry-run') || process.argv.includes('--count') + +/** + * Checks whether a column exists in a given table. + */ +async function columnExists(table, column) { + const [rows] = await db.sequelize.query( + `SELECT 1 + FROM information_schema.columns + WHERE table_name = :table + AND column_name = :column + LIMIT 1`, + { replacements: { table, column } } + ) + return rows.length > 0 +} + +/** + * Counts total rows and rows with a non-null value in the backup column. + */ +async function inspectColumn(table, column) { + const [[totalRow]] = await db.sequelize.query(`SELECT COUNT(*) AS count FROM "${table}"`) + const [[nonNullRow]] = await db.sequelize.query( + `SELECT COUNT(*) AS count FROM "${table}" WHERE "${column}" IS NOT NULL` + ) + return { + totalRows: parseInt(totalRow.count, 10), + nonNullRows: parseInt(nonNullRow.count, 10), + } +} + +async function main() { + console.log(`Mode: ${isDryRun ? 'DRY RUN (no changes)' : 'LIVE DROP'}`) + console.log('---') + + let hasError = false + + for (const { table, column } of TARGETS) { + const exists = await columnExists(table, column) + + if (!exists) { + console.log(`[${table}] Column "${column}" does not exist — already dropped or never created. Skipping.`) + continue + } + + const { totalRows, nonNullRows } = await inspectColumn(table, column) + console.log(`[${table}] "${column}" found.`) + console.log(` Total rows : ${totalRows}`) + console.log(` Non-null rows : ${nonNullRows}`) + + if (nonNullRows > 0 && !isDryRun) { + console.warn( + ` WARNING: ${nonNullRows} row(s) in "${table}" still have data in "${column}". ` + + `Dropping anyway — make sure you have a DB backup before proceeding.` + ) + } + + if (isDryRun) { + console.log(` [DRY RUN] Would run: ALTER TABLE "${table}" DROP COLUMN "${column}" CASCADE`) + console.log(' [DRY RUN] Dependent materialized views would be dropped and rebuilt.') + } else { + try { + await db.sequelize.query(`ALTER TABLE "${table}" DROP COLUMN IF EXISTS "${column}" CASCADE`) + console.log(` Dropped "${column}" from "${table}" (CASCADE).`) + } catch (err) { + console.error(` ERROR dropping "${column}" from "${table}": ${err.message}`) + hasError = true + } + } + + console.log('') + } + + if (isDryRun) { + console.log('Dry run complete. No changes were made.') + } else if (hasError) { + console.error('Completed with errors. Check output above.') + process.exit(1) + } else { + console.log('Rebuilding materialized views (dropped by CASCADE)...') + try { + await materializedViewsService.checkAndCreateMaterializedViews() + console.log('Materialized views rebuilt successfully.') + } catch (err) { + console.error('ERROR rebuilding materialized views:', err.message) + process.exit(1) + } + console.log('Done. Both backup columns have been dropped.') + console.log( + 'Note: migration 20260525000002-rename-visible-to-org-codes.js down() is no longer fully reversible.' + ) + } +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error('Unexpected error:', err) + process.exit(1) + }) diff --git a/src/services/mentees.js b/src/services/mentees.js index 4b133ef34..3d9387ad7 100644 --- a/src/services/mentees.js +++ b/src/services/mentees.js @@ -28,6 +28,7 @@ const defaultSearchConfig = require('@configs/search.json') const cacheHelper = require('@generics/cacheHelper') const emailEncryption = require('@utils/emailEncryption') const communicationHelper = require('@helpers/communications') +const { convertOrgIdsToOrgCodes } = require('@helpers/orgUtils') const { checkIfUserIsAccessible } = require('@helpers/saasUserAccessibility') const connectionQueries = require('@database/queries/connection') const getOrgIdAndEntityTypes = require('@helpers/getOrgIdAndEntityTypewithEntitiesBasedOnPolicy') @@ -720,15 +721,16 @@ module.exports = class MenteesHelper { defaultRuleFilter ) if (sessions && sessions.rows && Array.isArray(sessions.rows) && sessions.rows.length > 0) { - const uniqueOrgIds = [...new Set(sessions.rows.map((obj) => obj?.mentor_organization_id).filter(Boolean))] + const uniqueOrgCodes = [ + ...new Set(sessions.rows.map((obj) => obj?.mentor_organization_code).filter(Boolean)), + ] sessions.rows = await entityTypeService.processEntityTypesToAddValueLabels( sessions.rows, - uniqueOrgIds, + uniqueOrgCodes, common.sessionModelName, - 'mentor_organization_id', + 'mentor_organization_code', [], - tenantCode, - true + tenantCode ) } @@ -761,7 +763,7 @@ module.exports = class MenteesHelper { if (!menteeExtension) { menteeExtension = await menteeQueries.getMenteeExtension( userId, - ['external_session_visibility', 'organization_id', 'is_mentor'], + ['external_session_visibility', 'organization_id', 'organization_code', 'is_mentor'], false, tenantCode ) @@ -818,27 +820,27 @@ module.exports = class MenteesHelper { const userPolicyDetails = menteeExtension let filter = '' - if (userPolicyDetails.external_session_visibility && userPolicyDetails.organization_id) { + if (userPolicyDetails.external_session_visibility && userPolicyDetails.organization_code) { // generate filter based on condition if (userPolicyDetails.external_session_visibility === common.CURRENT) { /** * If {userPolicyDetails.external_session_visibility === CURRENT} user will be able to sessions- * -created by his/her organization mentors. - * So will check if mentor_organization_id equals user's organization_id + * So will check if mentor_organization_code equals user's organization_code */ - filter = `AND "mentor_organization_id" = '${userPolicyDetails.organization_id}'` + filter = `AND "mentor_organization_code" = '${userPolicyDetails.organization_code}'` } else if (userPolicyDetails.external_session_visibility === common.ASSOCIATED) { /** * user external_session_visibility is ASSOCIATED - * user can see sessions where session's visible_to_organizations contain user's organization_id and - - * - session's visibility not CURRENT (In case of same organization session has to be fetched for that we added OR condition {"mentor_organization_id" = ${userPolicyDetails.organization_id}}) + * user can see sessions where session's visible_to_organizations contain user's organization_code and - + * - session's visibility not CURRENT (In case of same organization session has to be fetched for that we added OR condition {"mentor_organization_code" = ${userPolicyDetails.organization_code}}) */ - filter = `AND (('${userPolicyDetails.organization_id}' = ANY("visible_to_organizations") AND "visibility" != 'CURRENT') OR "mentor_organization_id" = '${userPolicyDetails.organization_id}')` + filter = `AND (('${userPolicyDetails.organization_code}' = ANY("visible_to_organizations") AND "visibility" != 'CURRENT') OR "mentor_organization_code" = '${userPolicyDetails.organization_code}')` } else if (userPolicyDetails.external_session_visibility === common.ALL) { /** * user's external_session_visibility === ALL (ASSOCIATED sessions + sessions whose visibility is ALL) */ - filter = `AND (('${userPolicyDetails.organization_id}' = ANY("visible_to_organizations") AND "visibility" != 'CURRENT' ) OR "visibility" = 'ALL' OR "mentor_organization_id" = '${userPolicyDetails.organization_id}')` + filter = `AND (('${userPolicyDetails.organization_code}' = ANY("visible_to_organizations") AND "visibility" != 'CURRENT' ) OR "visibility" = 'ALL' OR "mentor_organization_code" = '${userPolicyDetails.organization_code}')` } } return filter @@ -874,15 +876,16 @@ module.exports = class MenteesHelper { return { rows: [], count: 0 } } if (sessionDetails.count > 0) { - const uniqueOrgIds = [...new Set(sessionDetails.rows.map((obj) => obj.mentor_organization_id))] + const uniqueOrgCodes = [ + ...new Set(sessionDetails.rows.map((obj) => obj.mentor_organization_code).filter(Boolean)), + ] sessionDetails.rows = await entityTypeService.processEntityTypesToAddValueLabels( sessionDetails.rows, - uniqueOrgIds, + uniqueOrgCodes, common.sessionModelName, - 'mentor_organization_id', + 'mentor_organization_code', [], - tenantCode, - true + tenantCode ) sessionDetails.rows = await this.sessionMentorDetails(sessionDetails.rows, tenantCode) sessionDetails.rows = sessionDetails.rows.map((r) => ({ ...r, is_enrolled: true })) @@ -1079,15 +1082,17 @@ module.exports = class MenteesHelper { // construct policy object let saasPolicyData = await orgAdminService.constructOrgPolicyObject(organisationPolicy, true) - userOrgDetails.data.result.related_orgs = userOrgDetails.data.result.related_orgs + const numericOrgIds = userOrgDetails.data.result.related_orgs ? userOrgDetails.data.result.related_orgs.concat([saasPolicyData.organization_id]) : [saasPolicyData.organization_id] + const visibleToOrgCodes = await convertOrgIdsToOrgCodes(numericOrgIds, tenantCode) + // Update mentee extension creation data data = { ...data, ...saasPolicyData, - visible_to_organizations: userOrgDetails.data.result.related_orgs, + visible_to_organizations: visibleToOrgCodes, } const response = await menteeQueries.createMenteeExtension(data, tenantCode) @@ -1804,40 +1809,25 @@ module.exports = class MenteesHelper { .join(',')}) ` } const requesterOrgCode = userPolicyDetails.organization_code - const requesterOrgId = userPolicyDetails.organization_id - // Important: visible_to_organizations stores organization IDs (from related_orgs), not codes - // So we must use organization_id when checking visible_to_organizations - if (getOrgPolicy?.external_mentee_visibility_policy && requesterOrgCode && requesterOrgId) { + if (getOrgPolicy?.external_mentee_visibility_policy && requesterOrgCode) { const visibilityPolicy = getOrgPolicy.external_mentee_visibility_policy // Filter user data based on policy // generate filter based on condition if (visibilityPolicy === common.CURRENT) { - /** - * if user external_mentor_visibility is current. He can only see his/her organizations mentors - * so we will check mentor's organization_id and user organization_id are matching - */ filter = `AND "organization_code" = '${requesterOrgCode}'` } else if (visibilityPolicy === common.ASSOCIATED) { - /** - * If user external_mentor_visibility is associated - * <> first we need to check if mentor's visible_to_organizations contain the user organization_id and verify mentor's visibility is not current (if it is ALL and ASSOCIATED it is accessible) - */ filter = additionalFilter + - `AND ( ('${requesterOrgId}' = ANY("visible_to_organizations") AND "mentee_visibility" != 'CURRENT')` + `AND ( ('${requesterOrgCode}' = ANY("visible_to_organizations") AND "mentee_visibility" != 'CURRENT')` if (additionalFilter.length === 0) filter += ` OR organization_code = '${requesterOrgCode}' )` else filter += `)` } else if (visibilityPolicy === common.ALL) { - /** - * We need to check if mentor's visible_to_organizations contain the user organization_id and verify mentor's visibility is not current (if it is ALL and ASSOCIATED it is accessible) - * OR if mentor visibility is ALL that mentor is also accessible - */ filter = additionalFilter + - `AND (('${requesterOrgId}' = ANY("visible_to_organizations") AND "mentee_visibility" != 'CURRENT' ) OR "mentee_visibility" = 'ALL' OR "organization_code" = '${requesterOrgCode}')` + `AND (('${requesterOrgCode}' = ANY("visible_to_organizations") AND "mentee_visibility" != 'CURRENT' ) OR "mentee_visibility" = 'ALL' OR "organization_code" = '${requesterOrgCode}')` } } diff --git a/src/services/mentors.js b/src/services/mentors.js index 586ba465a..4839b4e40 100644 --- a/src/services/mentors.js +++ b/src/services/mentors.js @@ -21,6 +21,7 @@ const menteesService = require('@services/mentees') const entityTypeService = require('@services/entity-type') const responses = require('@helpers/responses') const permissions = require('@helpers/getPermissions') +const { convertOrgIdsToOrgCodes } = require('@helpers/orgUtils') const { buildSearchFilter } = require('@helpers/search') const defaultSearchConfig = require('@configs/search.json') const emailEncryption = require('@utils/emailEncryption') @@ -162,18 +163,17 @@ module.exports = class MentorsHelper { } // Process entity types to add value labels. - const uniqueOrgIds = + const uniqueOrgCodes = upcomingSessions && upcomingSessions.data && Array.isArray(upcomingSessions.data) - ? [...new Set(upcomingSessions.data.map((obj) => obj.mentor_organization_id))] + ? [...new Set(upcomingSessions.data.map((obj) => obj.mentor_organization_code).filter(Boolean))] : [] upcomingSessions.data = await entityTypeService.processEntityTypesToAddValueLabels( upcomingSessions.data, - uniqueOrgIds, + uniqueOrgCodes, common.sessionModelName, - 'mentor_organization_id', + 'mentor_organization_code', [], - tenantCode, - true + tenantCode ) upcomingSessions.data = await this.sessionMentorDetails(upcomingSessions.data, tenantCode) @@ -488,14 +488,11 @@ module.exports = class MentorsHelper { // construct saas policy data let saasPolicyData = await orgAdminService.constructOrgPolicyObject(organisationPolicy, true) - // Set related_orgs to include current organization - const related_orgs = [saasPolicyData.organization_id] - // update mentee extension data data = { ...data, ...saasPolicyData, - visible_to_organizations: related_orgs, + visible_to_organizations: [orgCode], } const response = await mentorQueries.createMentorExtension(data, tenantCode) @@ -616,9 +613,12 @@ module.exports = class MentorsHelper { data.organization_id = data.organization.id const newPolicy = await orgAdminService.constructOrgPolicyObject(orgPolicies, true) data = _.merge({}, data, newPolicy) - data.visible_to_organizations = Array.from( - new Set([...userOrgDetails.data.result.related_orgs, data.organization.id]) + const relatedOrgCodes = await convertOrgIdsToOrgCodes( + userOrgDetails.data.result.related_orgs || [], + tenantCode ) + if (!relatedOrgCodes.includes(orgCode)) relatedOrgCodes.push(orgCode) + data.visible_to_organizations = relatedOrgCodes } const [updateCount, updatedMentor] = await mentorQueries.updateMentorExtension( @@ -1171,7 +1171,7 @@ module.exports = class MentorsHelper { if (!userPolicyDetails) { userPolicyDetails = await queryFunction( userId, - ['external_mentor_visibility', 'organization_id'], + ['external_mentor_visibility', 'organization_id', 'organization_code'], false, tenantCode ) @@ -1205,37 +1205,37 @@ module.exports = class MentorsHelper { // check the accessibility conditions let isAccessible = false - if (userPolicyDetails.external_mentor_visibility && userPolicyDetails.organization_id) { - const { external_mentor_visibility, organization_id } = userPolicyDetails + if (userPolicyDetails.external_mentor_visibility && userPolicyDetails.organization_code) { + const { external_mentor_visibility, organization_code } = userPolicyDetails const mentor = userData[0] switch (external_mentor_visibility) { /** * if user external_mentor_visibility is current. He can only see his/her organizations mentors - * so we will check mentor's organization_id and user organization_id are matching + * so we will check mentor's organization_code and user organization_code are matching */ case common.CURRENT: - isAccessible = mentor.organization_id === organization_id + isAccessible = mentor.organization_code === organization_code break /** * If user external_mentor_visibility is associated - * <> first we need to check if mentor's visible_to_organizations contain the user organization_id and verify mentor's visibility is not current (if it is ALL and ASSOCIATED it is accessible) + * <> first we need to check if mentor's visible_to_organizations contain the user organization_code and verify mentor's visibility is not current (if it is ALL and ASSOCIATED it is accessible) */ case common.ASSOCIATED: isAccessible = - (mentor.visible_to_organizations.includes(organization_id) && + (mentor.visible_to_organizations.includes(organization_code) && mentor.mentor_visibility != common.CURRENT) || - mentor.organization_id === organization_id + mentor.organization_code === organization_code break /** - * We need to check if mentor's visible_to_organizations contain the user organization_id and verify mentor's visibility is not current (if it is ALL and ASSOCIATED it is accessible) + * We need to check if mentor's visible_to_organizations contain the user organization_code and verify mentor's visibility is not current (if it is ALL and ASSOCIATED it is accessible) * OR if mentor visibility is ALL that mentor is also accessible */ case common.ALL: isAccessible = - (mentor.visible_to_organizations.includes(organization_id) && + (mentor.visible_to_organizations.includes(organization_code) && mentor.mentor_visibility != common.CURRENT) || mentor.mentor_visibility === common.ALL || - mentor.organization_id === organization_id + mentor.organization_code === organization_code break default: break @@ -1649,7 +1649,7 @@ module.exports = class MentorsHelper { filter = additionalFilter + - `AND ( ('${userPolicyDetails.organization_id}' = ANY("visible_to_organizations") AND "mentor_visibility" != 'CURRENT')` + `AND ( ('${userPolicyDetails.organization_code}' = ANY("visible_to_organizations") AND "mentor_visibility" != 'CURRENT')` if (additionalFilter.length === 0) filter += ` OR organization_code = '${userPolicyDetails.organization_code}' )` @@ -1661,7 +1661,7 @@ module.exports = class MentorsHelper { */ filter = additionalFilter + - `AND (('${userPolicyDetails.organization_id}' = ANY("visible_to_organizations") AND "mentor_visibility" != 'CURRENT' ) OR "mentor_visibility" = 'ALL' OR "organization_code" = '${userPolicyDetails.organization_code}')` + `AND (('${userPolicyDetails.organization_code}' = ANY("visible_to_organizations") AND "mentor_visibility" != 'CURRENT' ) OR "mentor_visibility" = 'ALL' OR "organization_code" = '${userPolicyDetails.organization_code}')` } } diff --git a/src/services/org-admin.js b/src/services/org-admin.js index 98caf100b..2d9896040 100644 --- a/src/services/org-admin.js +++ b/src/services/org-admin.js @@ -16,6 +16,7 @@ const { Op } = require('sequelize') const responses = require('@helpers/responses') const { getDefaults } = require('@helpers/getDefaultOrgId') const cacheHelper = require('@generics/cacheHelper') +const { convertOrgIdsToOrgCodes } = require('@helpers/orgUtils') module.exports = class OrgAdminService { /** @@ -97,9 +98,13 @@ module.exports = class OrgAdminService { mentorDetails.organization_id = bodyData.organization_id const newPolicy = await this.constructOrgPolicyObject(orgPolicies) mentorDetails = _.merge({}, mentorDetails, newPolicy, updateData) - mentorDetails.visible_to_organizations = Array.from( - new Set([...(organizationDetails.data.result.related_orgs || []), bodyData.organization_id]) + const relatedOrgCodes1 = await convertOrgIdsToOrgCodes( + organizationDetails.data.result.related_orgs || [], + tenantCode ) + if (!relatedOrgCodes1.includes(bodyData.organization_code)) + relatedOrgCodes1.push(bodyData.organization_code) + mentorDetails.visible_to_organizations = relatedOrgCodes1 } mentorDetails.is_mentor = false if (mentorDetails.email) delete mentorDetails.email @@ -227,9 +232,13 @@ module.exports = class OrgAdminService { menteeDetails.organization_code = bodyData.organization_code const newPolicy = await this.constructOrgPolicyObject(orgPolicies) menteeDetails = _.merge({}, menteeDetails, newPolicy, updateData) - menteeDetails.visible_to_organizations = Array.from( - new Set([...(organizationDetails.data.result.related_orgs || []), bodyData.organization_id]) + const relatedOrgCodes2 = await convertOrgIdsToOrgCodes( + organizationDetails.data.result.related_orgs || [], + tenantCode ) + if (!relatedOrgCodes2.includes(bodyData.organization_code)) + relatedOrgCodes2.push(bodyData.organization_code) + menteeDetails.visible_to_organizations = relatedOrgCodes2 } if (menteeDetails.email) delete menteeDetails.email @@ -302,11 +311,13 @@ module.exports = class OrgAdminService { organizationId: decodedToken.organization_id, tenantCode, }) - policyData.visible_to_organizations = organizationDetails.data.result.related_orgs || [] - - if (!policyData.visible_to_organizations.includes(decodedToken.organization_id)) { - policyData.visible_to_organizations.push(decodedToken.organization_id) - } + const relatedOrgCodes3 = await convertOrgIdsToOrgCodes( + organizationDetails.data.result.related_orgs || [], + tenantCode + ) + if (!relatedOrgCodes3.includes(decodedToken.organization_code)) + relatedOrgCodes3.push(decodedToken.organization_code) + policyData.visible_to_organizations = relatedOrgCodes3 } //Update all users belonging to the org with new policies @@ -517,6 +528,12 @@ module.exports = class OrgAdminService { } //Update the policy + const relatedOrgCodes4 = await convertOrgIdsToOrgCodes( + organizationDetails.data.result.related_orgs || [], + tenantCode + ) + if (!relatedOrgCodes4.includes(bodyData.organization_code)) + relatedOrgCodes4.push(bodyData.organization_code) const updateData = { organization_id: orgId, external_session_visibility: orgPolicies.external_session_visibility_policy, @@ -524,10 +541,7 @@ module.exports = class OrgAdminService { mentor_visibility: orgPolicies.mentor_visibility_policy, mentee_visibility: orgPolicies.mentee_visibility_policy, external_mentee_visibility: orgPolicies.external_mentee_visibility_policy, - visible_to_organizations: organizationDetails.data.result.related_orgs || [], - } - if (!updateData.visible_to_organizations.includes(orgId)) { - updateData.visible_to_organizations.push(orgId) + visible_to_organizations: relatedOrgCodes4, } if (utils.validateRoleAccess(bodyData.roles, common.MENTOR_ROLE)) { @@ -673,9 +687,9 @@ module.exports = class OrgAdminService { try { orgId = orgId.toString() deltaOrganizationIds = deltaOrganizationIds.map(String) - if (action === common.PUSH) { + if (action?.toLowerCase() === common.PUSH.toLowerCase()) { await menteeQueries.addVisibleToOrg(orgId, deltaOrganizationIds, tenantCode) - } else if (action === common.POP) { + } else if (action?.toLowerCase() === common.POP.toLowerCase()) { await menteeQueries.removeVisibleToOrg(orgId, deltaOrganizationIds, tenantCode) } diff --git a/src/services/sessions.js b/src/services/sessions.js index ffc5294e5..4bbcea9ea 100644 --- a/src/services/sessions.js +++ b/src/services/sessions.js @@ -37,6 +37,7 @@ const path = require('path') const ProjectRootDir = path.join(__dirname, '../') const inviteeFileDir = ProjectRootDir + common.tempFolderForBulkUpload const fileUploadQueries = require('@database/queries/fileUpload') +const { convertOrgIdsToOrgCodes } = require('@helpers/orgUtils') const { Queue } = require('bullmq') const fs = require('fs') const csv = require('csvtojson') @@ -310,6 +311,7 @@ module.exports = class SessionsHelper { } bodyData['mentor_organization_id'] = orgId + bodyData['mentor_organization_code'] = orgCode // SAAS changes; Include visibility and visible organisation // Call user service to fetch organisation details --SAAS related changes let userOrgDetails = await userRequests.fetchOrgDetails({ organizationCode: orgCode, tenantCode }) @@ -350,9 +352,12 @@ module.exports = class SessionsHelper { tenantCode ) bodyData.visibility = organisationPolicy.session_visibility_policy - bodyData.visible_to_organizations = userOrgDetails.data.result.related_orgs - ? userOrgDetails.data.result.related_orgs.concat([orgId]) - : [orgId] + const relatedOrgCodes = await convertOrgIdsToOrgCodes( + userOrgDetails.data.result.related_orgs || [], + tenantCode + ) + if (!relatedOrgCodes.includes(orgCode)) relatedOrgCodes.push(orgCode) + bodyData.visible_to_organizations = relatedOrgCodes if (organisationPolicy.mentee_feedback_question_set) bodyData.mentee_feedback_question_set = organisationPolicy.mentee_feedback_question_set if (organisationPolicy.mentor_feedback_question_set) @@ -1732,14 +1737,14 @@ module.exports = class SessionsHelper { sessionEntityTypes = await cacheHelper.entityTypes.getEntityTypesWithMentorOrg( tenantCode, orgCode, - sessionDetails.mentor_organization_id, + sessionDetails.mentor_organization_code, sessionModelName ) accessorEntityTypes = await cacheHelper.entityTypes.getEntityTypesWithMentorOrg( tenantCode, sessionAccessorDetails.organization_code, - sessionAccessorDetails.organization_id, + sessionAccessorDetails.organization_code, mentorExtensionsModelName ) @@ -1878,31 +1883,31 @@ module.exports = class SessionsHelper { // check the accessibility conditions let isAccessible = false - if (userPolicyDetails.external_session_visibility && userPolicyDetails.organization_id) { - const { external_session_visibility, organization_id } = userPolicyDetails + if (userPolicyDetails.external_session_visibility && userPolicyDetails.organization_code) { + const { external_session_visibility, organization_code } = userPolicyDetails const isEnrolled = session.is_enrolled || false switch (external_session_visibility) { /** * If {userPolicyDetails.external_session_visibility === CURRENT} user will be able to sessions- * -created by his/her organization mentors. - * So will check if mentor_organization_id equals user's organization_id + * So will check if mentor_organization_code equals user's organization_code */ case common.CURRENT: - isAccessible = isEnrolled || session.mentor_organization_id === organization_id + isAccessible = isEnrolled || session.mentor_organization_code === organization_code break /** * user external_session_visibility is ASSOCIATED - * user can see sessions where session's visible_to_organizations contain user's organization_id and - + * user can see sessions where session's visible_to_organizations contain user's organization_code and - * - session's visibility not CURRENT (In case of same organization session has to be - * fetched for that we added OR condition {"mentor_organization_id" = ${userPolicyDetails.organization_id}}) + * fetched for that we added OR condition {"mentor_organization_code" = ${userPolicyDetails.organization_code}}) */ case common.ASSOCIATED: isAccessible = isEnrolled || - (session.visible_to_organizations.includes(organization_id) && + (session.visible_to_organizations.includes(organization_code) && session.visibility != common.CURRENT) || - session.mentor_organization_id === organization_id + session.mentor_organization_code === organization_code break /** * user's external_session_visibility === ALL (ASSOCIATED sessions + sessions whose visibility is ALL) @@ -1910,10 +1915,10 @@ module.exports = class SessionsHelper { case common.ALL: isAccessible = isEnrolled || - (session.visible_to_organizations.includes(organization_id) && + (session.visible_to_organizations.includes(organization_code) && session.visibility != common.CURRENT) || session.visibility === common.ALL || - session.mentor_organization_id === organization_id + session.mentor_organization_code === organization_code break default: break @@ -2761,13 +2766,8 @@ module.exports = class SessionsHelper { const session = await sessionQueries.getSessionTenantCode(sessionId) if (!session) return null - if (!orgCode && session.mentor_organization_id && session.tenant_code) { - const orgExtension = await organisationExtensionQueries.findOne( - { organization_id: session.mentor_organization_id }, - session.tenant_code, - { attributes: ['organization_code'], raw: true } - ) - orgCode = orgExtension?.organization_code || null + if (!orgCode) { + orgCode = session.mentor_organization_code || null } return { @@ -3256,15 +3256,14 @@ module.exports = class SessionsHelper { tenantCode ) { try { - const uniqueOrgIds = [...new Set(sessions.map((obj) => obj.mentor_organization_id))] + const uniqueOrgCodes = [...new Set(sessions.map((obj) => obj.mentor_organization_code).filter(Boolean))] sessions = await entityTypeService.processEntityTypesToAddValueLabels( sessions, - uniqueOrgIds, + uniqueOrgCodes, common.sessionModelName, - 'mentor_organization_id', + 'mentor_organization_code', [], - tenantCode, - true + tenantCode ) await Promise.all( @@ -3371,7 +3370,7 @@ module.exports = class SessionsHelper { duration_in_minutes: session.duration_in_minutes, status: session.status, mentee_count: session.mentee_count, - mentor_organization_id: session.mentor_organization_id, + mentor_organization_code: session.mentor_organization_code, mentor_id: session.mentor_id, })) From eab19b7d524a020acddd591dc24381d1d60beae0 Mon Sep 17 00:00:00 2001 From: borkarsaish65 Date: Wed, 27 May 2026 16:19:59 +0530 Subject: [PATCH 3/5] addressed CR comments --- src/scripts/dropOrgNumericBackupColumns.js | 27 ++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/scripts/dropOrgNumericBackupColumns.js b/src/scripts/dropOrgNumericBackupColumns.js index e39ffbdba..e60d21448 100644 --- a/src/scripts/dropOrgNumericBackupColumns.js +++ b/src/scripts/dropOrgNumericBackupColumns.js @@ -77,6 +77,7 @@ async function main() { console.log('---') let hasError = false + let droppedAny = false for (const { table, column } of TARGETS) { const exists = await columnExists(table, column) @@ -105,6 +106,7 @@ async function main() { try { await db.sequelize.query(`ALTER TABLE "${table}" DROP COLUMN IF EXISTS "${column}" CASCADE`) console.log(` Dropped "${column}" from "${table}" (CASCADE).`) + droppedAny = true } catch (err) { console.error(` ERROR dropping "${column}" from "${table}": ${err.message}`) hasError = true @@ -116,18 +118,25 @@ async function main() { if (isDryRun) { console.log('Dry run complete. No changes were made.') - } else if (hasError) { - console.error('Completed with errors. Check output above.') - process.exit(1) } else { - console.log('Rebuilding materialized views (dropped by CASCADE)...') - try { - await materializedViewsService.checkAndCreateMaterializedViews() - console.log('Materialized views rebuilt successfully.') - } catch (err) { - console.error('ERROR rebuilding materialized views:', err.message) + // Always rebuild views if any column was dropped via CASCADE, even on partial failure, + // so dependent views aren't left missing after a successful drop. + if (droppedAny) { + console.log('Rebuilding materialized views (dropped by CASCADE)...') + try { + await materializedViewsService.checkAndCreateMaterializedViews() + console.log('Materialized views rebuilt successfully.') + } catch (err) { + console.error('ERROR rebuilding materialized views:', err.message) + process.exit(1) + } + } + + if (hasError) { + console.error('Completed with errors. Check output above.') process.exit(1) } + console.log('Done. Both backup columns have been dropped.') console.log( 'Note: migration 20260525000002-rename-visible-to-org-codes.js down() is no longer fully reversible.' From 622937f94cdb798844f4aa0655a6b3d0c57d7091 Mon Sep 17 00:00:00 2001 From: borkarsaish65 Date: Wed, 27 May 2026 16:31:00 +0530 Subject: [PATCH 4/5] addressed CR comments --- src/controllers/v1/org-admin.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/controllers/v1/org-admin.js b/src/controllers/v1/org-admin.js index e7c57bcc8..0c6faaea7 100644 --- a/src/controllers/v1/org-admin.js +++ b/src/controllers/v1/org-admin.js @@ -1,6 +1,8 @@ const orgAdminService = require('@services/org-admin') const common = require('@constants/common') const { convertOrgIdsToOrgCodes } = require('@helpers/orgUtils') +const responses = require('@helpers/responses') +const httpStatusCode = require('@generics/http-status') module.exports = class OrgAdmin { /** @@ -139,6 +141,13 @@ module.exports = class OrgAdmin { const tenantCode = req.body.tenant_code const deltaOrgCodes = await convertOrgIdsToOrgCodes(req.body.delta_organization_ids || [], tenantCode) const [orgCode] = await convertOrgIdsToOrgCodes([req.body.organization_id], tenantCode) + if (!orgCode) { + return responses.failureResponse({ + message: 'ORGANIZATION_NOT_FOUND', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } return await orgAdminService.updateRelatedOrgs(deltaOrgCodes, orgCode, req.body.action, tenantCode) } catch (error) { return error From 9a20f1373f773fbdbdbdee722f44398a85409b30 Mon Sep 17 00:00:00 2001 From: borkarsaish65 Date: Wed, 27 May 2026 16:43:08 +0530 Subject: [PATCH 5/5] addressed CR comments Co-Authored-By: Claude Sonnet 4.6 --- .../20260525000001-backfill-org-codes-from-ids.js | 4 ++-- src/database/queries/mentorExtension.js | 7 +++++-- src/database/queries/userExtension.js | 7 +++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/database/migrations/20260525000001-backfill-org-codes-from-ids.js b/src/database/migrations/20260525000001-backfill-org-codes-from-ids.js index 3c368b452..a90b61af9 100644 --- a/src/database/migrations/20260525000001-backfill-org-codes-from-ids.js +++ b/src/database/migrations/20260525000001-backfill-org-codes-from-ids.js @@ -49,7 +49,7 @@ module.exports = { const toCodeArray = (arr, tenantCode) => arr.map((id) => toCode(id, tenantCode) ?? id) // Format a JS string array as a Postgres array literal: {val1,val2} - const pgArr = (arr) => `{${arr.join(',')}}` + const pgArr = (arr) => `{${arr.map((v) => `"${v.replace(/"/g, '\\"')}"`).join(',')}}` // ── Step 1: sessions.mentor_organization_code ───────────────────────── console.log('\nStep 1: Backfilling sessions.mentor_organization_code ...') @@ -65,7 +65,7 @@ module.exports = { if (sessionCodeUpdates.length) { // Batch all updates in one VALUES-based UPDATE — no per-row round trips - const values = sessionCodeUpdates.map((u) => `(${u.id}, '${u.code}')`).join(', ') + const values = sessionCodeUpdates.map((u) => `(${u.id}, '${u.code.replace(/'/g, "''")}')`).join(', ') await queryInterface.sequelize.query(` UPDATE sessions SET mentor_organization_code = v.code diff --git a/src/database/queries/mentorExtension.js b/src/database/queries/mentorExtension.js index e741962e3..2818e85eb 100644 --- a/src/database/queries/mentorExtension.js +++ b/src/database/queries/mentorExtension.js @@ -358,7 +358,7 @@ module.exports = class MentorExtensionQueries { { visible_to_organizations: sequelize.literal( `array_cat(COALESCE("visible_to_organizations", ARRAY[]::varchar[]), ARRAY[${newRelatedOrgs - .map((v) => `'${v}'`) + .map((v) => `'${v.replace(/'/g, "''")}'`) .join(',')}]::varchar[])` ), }, @@ -387,7 +387,10 @@ module.exports = class MentorExtensionQueries { return await MentorExtension.update( { visible_to_organizations: sequelize.literal( - `COALESCE("visible_to_organizations", ARRAY[]::varchar[]) || ARRAY['${organizationId}']::varchar[]` + `COALESCE("visible_to_organizations", ARRAY[]::varchar[]) || ARRAY['${organizationId.replace( + /'/g, + "''" + )}']::varchar[]` ), }, { diff --git a/src/database/queries/userExtension.js b/src/database/queries/userExtension.js index 70ce1aee6..b78cefe35 100644 --- a/src/database/queries/userExtension.js +++ b/src/database/queries/userExtension.js @@ -112,7 +112,7 @@ module.exports = class MenteeExtensionQueries { const newRelatedOrgsArray = Array.from(newRelatedOrgs.values()) - const newRelatedOrgsSql = newRelatedOrgsArray.map((e) => `'${e}'`).join(',') + const newRelatedOrgsSql = newRelatedOrgsArray.map((e) => `'${e.replace(/'/g, "''")}'`).join(',') await MenteeExtension.update( { @@ -147,7 +147,10 @@ module.exports = class MenteeExtensionQueries { const result = await MenteeExtension.update( { visible_to_organizations: sequelize.literal( - `COALESCE("visible_to_organizations", ARRAY[]::varchar[]) || ARRAY['${organizationId}']::varchar[]` + `COALESCE("visible_to_organizations", ARRAY[]::varchar[]) || ARRAY['${organizationId.replace( + /'/g, + "''" + )}']::varchar[]` ), }, {