Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.')
},
}
Original file line number Diff line number Diff line change
@@ -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.')
},
}
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
4 changes: 3 additions & 1 deletion src/database/queries/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions src/database/queries/userExtension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand All @@ -123,7 +123,7 @@ module.exports = class MenteeExtensionQueries {
{
where: {
tenant_code: tenantCode,
organization_id: organizationId,
organization_code: organizationId,
[Op.or]: [
{
[Op.not]: {
Expand All @@ -144,16 +144,19 @@ 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.replace(
/'/g,
"''"
)}']::varchar[]`
),
},
{
where: {
tenant_code: tenantCode,
organization_id: {
organization_code: {
[Op.in]: newRelatedOrgsArray,
},
[Op.or]: [
Expand All @@ -175,6 +178,7 @@ module.exports = class MenteeExtensionQueries {
...otherOptions,
}
)
return result
}

static async removeVisibleToOrg(orgId, elementsToRemove, tenantCode) {
Expand All @@ -185,7 +189,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, {
Expand All @@ -199,7 +203,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, {
Expand Down
Loading