Skip to content
6 changes: 6 additions & 0 deletions src/constants/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,12 @@ module.exports = {
defaultTtl: 86400, // 1 day
useInternal: false,
},
orgIdCode: {
name: 'orgIdCode',
enabled: true,
defaultTtl: 86400, // 1 day
useInternal: false,
},
},
},

Expand Down
20 changes: 14 additions & 6 deletions src/controllers/v1/org-admin.js
Original file line number Diff line number Diff line change
@@ -1,5 +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 {
/**
Expand Down Expand Up @@ -135,12 +138,17 @@ 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)
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
157 changes: 157 additions & 0 deletions src/database/migrations/20260525000001-backfill-org-codes-from-ids.js
Original file line number Diff line number Diff line change
@@ -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.map((v) => `"${v.replace(/"/g, '\\"')}"`).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.replace(/'/g, "''")}')`).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++
}
Comment thread
borkarsaish65 marked this conversation as resolved.

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.')
},
}
110 changes: 110 additions & 0 deletions src/database/migrations/20260525000002-rename-visible-to-org-codes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'use strict'
require('module-alias/register')
require('dotenv').config()
const materializedViewsService = require('@generics/materializedViews')

/**
* 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) {
// All four renames run in a single transaction — if any rename fails the
// entire swap rolls back, keeping the schema consistent and the migration re-runnable.
await queryInterface.sequelize.transaction(async (t) => {
await queryInterface.renameColumn(
'sessions',
'visible_to_organizations',
'visible_to_organizations_numeric',
{
transaction: t,
}
)
await queryInterface.renameColumn('sessions', 'visible_to_organization_codes', 'visible_to_organizations', {
transaction: t,
})
console.log(
'sessions: visible_to_organizations (numeric) → visible_to_organizations_numeric (backup); visible_to_organization_codes → visible_to_organizations'
)

await queryInterface.renameColumn(
'user_extensions',
'visible_to_organizations',
'visible_to_organizations_numeric',
{ transaction: t }
)
await queryInterface.renameColumn(
'user_extensions',
'visible_to_organization_codes',
'visible_to_organizations',
{ transaction: t }
)
console.log(
'user_extensions: visible_to_organizations (numeric) → visible_to_organizations_numeric (backup); visible_to_organization_codes → visible_to_organizations'
)
})

// Rebuild materialized views outside the transaction — long-running, best-effort.
// visible_to_organizations is now varchar[] not integer[]; existing views must be replaced.
console.log('Rebuilding materialized views for all tenants...')
const result = await materializedViewsService.triggerViewBuildForAllTenants()
if (!result || !result.success) {
throw new Error(
`Materialized view rebuild failed after column rename: ${result?.message || 'unknown error'}`
)
}
console.log('Materialized view rebuild complete')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},

async down(queryInterface) {
// Fully reversible: rename both columns back to their pre-up names in a single transaction.
// Numeric ID data is preserved in visible_to_organizations_numeric throughout.
await queryInterface.sequelize.transaction(async (t) => {
await queryInterface.renameColumn('sessions', 'visible_to_organizations', 'visible_to_organization_codes', {
transaction: t,
})
await queryInterface.renameColumn(
'sessions',
'visible_to_organizations_numeric',
'visible_to_organizations',
{
transaction: t,
}
)

await queryInterface.renameColumn(
'user_extensions',
'visible_to_organizations',
'visible_to_organization_codes',
{ transaction: t }
)
await queryInterface.renameColumn(
'user_extensions',
'visible_to_organizations_numeric',
'visible_to_organizations',
{ transaction: t }
)
console.log('Reverted rename. Numeric ID data fully restored in visible_to_organizations.')
})

// Rebuild views to reflect the reverted column names and types
console.log('Rebuilding materialized views for all tenants...')
const result = await materializedViewsService.triggerViewBuildForAllTenants()
if (!result || !result.success) {
throw new Error(
`Materialized view rebuild failed after column rename revert: ${result?.message || 'unknown error'}`
)
}
console.log('Materialized view rebuild complete')
},
}
34 changes: 34 additions & 0 deletions src/database/migrations/20260525000003-add-org-code-indexes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict'

/**
* Adds composite indexes to back the new organization_code predicates introduced
* by the org-code migration (Issue 3):
*
* - user_extensions(tenant_code, organization_code): backs both UPDATE queries in
* addVisibleToOrg which filter on WHERE organization_code = $2 AND tenant_code = $3
*
* - sessions(tenant_code, mentor_organization_code): backs queries that filter
* sessions by the new mentor_organization_code column added in migration 00001
*/
module.exports = {
async up(queryInterface) {
await queryInterface.addIndex('user_extensions', ['tenant_code', 'organization_code'], {
name: 'idx_user_extensions_tenant_org_code',
})
console.log(
'Created index idx_user_extensions_tenant_org_code on user_extensions(tenant_code, organization_code)'
)

await queryInterface.addIndex('sessions', ['tenant_code', 'mentor_organization_code'], {
name: 'idx_sessions_tenant_mentor_org_code',
})
console.log(
'Created index idx_sessions_tenant_mentor_org_code on sessions(tenant_code, mentor_organization_code)'
)
},

async down(queryInterface) {
await queryInterface.removeIndex('user_extensions', 'idx_user_extensions_tenant_org_code')
await queryInterface.removeIndex('sessions', 'idx_sessions_tenant_mentor_org_code')
},
}
4 changes: 4 additions & 0 deletions src/database/models/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions src/database/queries/mentorExtension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.replace(/'/g, "''")}'`)
.join(',')}]::varchar[])`
),
Comment thread
borkarsaish65 marked this conversation as resolved.
},
{
Expand All @@ -384,8 +386,12 @@ 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.replace(
/'/g,
"''"
)}']::varchar[]`
),
},
{
where: {
Expand Down
Loading