feat(cohorts): rename Programs to Cohorts with data migration (#694)

* feat(cohorts): rename Programs to Cohorts with data migration

- Add cohort database tables and Drizzle migration alongside legacy program tables
- Introduce migrate-programs-to-cohorts script to copy existing program data safely
- Rename API routes (/cohort), services, queries, validation, and error codes
- Rename dashboard feature module, routes (/cohorts), navigation, and translations
- Update docs, emails, and internal compliance cron endpoints for cohort terminology
- Keep legacy program tables in place for rollback until explicitly removed

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(db): restore migrate:answer-data script alongside cohort migration

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(db): cast program enums when migrating data to cohort tables

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(db): truncate cohort tables before program-to-cohort copy

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(db): report accurate row counts in programs-to-cohorts migration

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-07-02 12:10:31 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent fe9f8c441f
commit ec61bfe7d9
115 changed files with 19703 additions and 6046 deletions
+21 -21
View File
@@ -22,14 +22,14 @@ import {
} from '@cio/db/queries/organization';
import { getCourseGroupIds } from '@cio/db/queries/course';
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
import { addProgramMember, getExistingProgramMembers } from '@cio/db/queries/program';
import { addCohortMember, getExistingCohortMembers } from '@cio/db/queries/cohort';
import { ROLE } from '@cio/utils/constants';
import type { TNewOrganizationInviteAudit } from '@db/types';
import crypto from 'node:crypto';
import { db, type DbOrTxClient } from '@cio/db/drizzle';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { parseCourseIdsFromInviteMetadata, parseCohortIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { buildEmailBranding } from '@cio/email';
@@ -404,7 +404,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
if (invite) {
const metadata = invite.invite.metadata;
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
const programIds = parseProgramIdsFromInviteMetadata(metadata);
const cohortIds = parseCohortIdsFromInviteMetadata(metadata);
if (courseIds.length > 0) {
try {
@@ -422,19 +422,19 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
}
}
if (programIds.length > 0) {
if (cohortIds.length > 0) {
try {
const existingProgramMemberships = await getExistingProgramMembers(
programIds.map((programId) => ({ programId, profileId: user.id }))
const existingCohortMemberships = await getExistingCohortMembers(
cohortIds.map((cohortId) => ({ cohortId, profileId: user.id }))
);
const programIdsToInsert = programIds.filter(
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
const cohortIdsToInsert = cohortIds.filter(
(cohortId) => !existingCohortMemberships.has(`${cohortId}:${user.id}`)
);
await Promise.all(
programIdsToInsert.map((programId) =>
addProgramMember({
programId,
cohortIdsToInsert.map((cohortId) =>
addCohortMember({
cohortId,
roleId: invite.invite.roleId,
profileId: user.id,
email: normalizedEmail
@@ -442,7 +442,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
)
);
} catch (error) {
console.error('acceptOrganizationInvite program enrollment error:', error);
console.error('acceptOrganizationInvite cohort enrollment error:', error);
}
}
}
@@ -697,7 +697,7 @@ export async function acceptOrganizationInviteById(
const metadata = result.invite.metadata;
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
const programIds = parseProgramIdsFromInviteMetadata(metadata);
const cohortIds = parseCohortIdsFromInviteMetadata(metadata);
if (courseIds.length > 0) {
try {
@@ -714,22 +714,22 @@ export async function acceptOrganizationInviteById(
}
}
if (programIds.length > 0) {
if (cohortIds.length > 0) {
try {
const existingProgramMemberships = await getExistingProgramMembers(
programIds.map((programId) => ({ programId, profileId: user.id }))
const existingCohortMemberships = await getExistingCohortMembers(
cohortIds.map((cohortId) => ({ cohortId, profileId: user.id }))
);
const programIdsToInsert = programIds.filter(
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
const cohortIdsToInsert = cohortIds.filter(
(cohortId) => !existingCohortMemberships.has(`${cohortId}:${user.id}`)
);
await Promise.all(
programIdsToInsert.map((programId) =>
addProgramMember({ programId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail })
cohortIdsToInsert.map((cohortId) =>
addCohortMember({ cohortId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail })
)
);
} catch (error) {
console.error('acceptOrganizationInviteById program enrollment error:', error);
console.error('acceptOrganizationInviteById cohort enrollment error:', error);
}
}