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 <[email protected]>

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

Co-authored-by: Rotimi Best <[email protected]>

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

Co-authored-by: Rotimi Best <[email protected]>

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

Co-authored-by: Rotimi Best <[email protected]>

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

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
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
+16 -16
View File
@@ -157,22 +157,22 @@ export const ErrorCodes = {
// Token auth admin errors
TOKEN_AUTH_ALREADY_EXISTS: 'TOKEN_AUTH_ALREADY_EXISTS',
// Program errors
PROGRAM_NOT_FOUND: 'PROGRAM_NOT_FOUND',
PROGRAM_FORBIDDEN: 'PROGRAM_FORBIDDEN',
PROGRAM_CREATE_FAILED: 'PROGRAM_CREATE_FAILED',
PROGRAM_UPDATE_FAILED: 'PROGRAM_UPDATE_FAILED',
PROGRAM_DELETE_FAILED: 'PROGRAM_DELETE_FAILED',
COURSE_ALREADY_IN_PROGRAM: 'COURSE_ALREADY_IN_PROGRAM',
MEMBER_ALREADY_IN_PROGRAM: 'MEMBER_ALREADY_IN_PROGRAM',
PROGRAM_MEMBER_NOT_FOUND: 'PROGRAM_MEMBER_NOT_FOUND',
PROGRAM_NEWSFEED_NOT_FOUND: 'PROGRAM_NEWSFEED_NOT_FOUND',
PROGRAM_NEWSFEED_COMMENT_NOT_FOUND: 'PROGRAM_NEWSFEED_COMMENT_NOT_FOUND',
PROGRAM_MEMBER_CHECK_FAILED: 'PROGRAM_MEMBER_CHECK_FAILED',
PROGRAM_GOAL_NOT_FOUND: 'PROGRAM_GOAL_NOT_FOUND',
PROGRAM_GOAL_CREATE_FAILED: 'PROGRAM_GOAL_CREATE_FAILED',
PROGRAM_GOAL_UPDATE_FAILED: 'PROGRAM_GOAL_UPDATE_FAILED',
PROGRAM_GOAL_DELETE_FAILED: 'PROGRAM_GOAL_DELETE_FAILED',
// Cohort errors
COHORT_NOT_FOUND: 'COHORT_NOT_FOUND',
COHORT_FORBIDDEN: 'COHORT_FORBIDDEN',
COHORT_CREATE_FAILED: 'COHORT_CREATE_FAILED',
COHORT_UPDATE_FAILED: 'COHORT_UPDATE_FAILED',
COHORT_DELETE_FAILED: 'COHORT_DELETE_FAILED',
COURSE_ALREADY_IN_COHORT: 'COURSE_ALREADY_IN_COHORT',
MEMBER_ALREADY_IN_COHORT: 'MEMBER_ALREADY_IN_COHORT',
COHORT_MEMBER_NOT_FOUND: 'COHORT_MEMBER_NOT_FOUND',
COHORT_NEWSFEED_NOT_FOUND: 'COHORT_NEWSFEED_NOT_FOUND',
COHORT_NEWSFEED_COMMENT_NOT_FOUND: 'COHORT_NEWSFEED_COMMENT_NOT_FOUND',
COHORT_MEMBER_CHECK_FAILED: 'COHORT_MEMBER_CHECK_FAILED',
COHORT_GOAL_NOT_FOUND: 'COHORT_GOAL_NOT_FOUND',
COHORT_GOAL_CREATE_FAILED: 'COHORT_GOAL_CREATE_FAILED',
COHORT_GOAL_UPDATE_FAILED: 'COHORT_GOAL_UPDATE_FAILED',
COHORT_GOAL_DELETE_FAILED: 'COHORT_GOAL_DELETE_FAILED',
// AI assistant chat
NO_MESSAGES_TO_COMPACT: 'NO_MESSAGES_TO_COMPACT',
@@ -1,27 +1,27 @@
import * as z from 'zod';
import { ROLE } from '@cio/utils/constants';
export const ZCreateProgram = z.object({
export const ZCreateCohort = z.object({
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
coverImage: z.string().url().optional()
});
export type TCreateProgram = z.infer<typeof ZCreateProgram>;
export type TCreateCohort = z.infer<typeof ZCreateCohort>;
export const ZUpdateProgram = z.object({
export const ZUpdateCohort = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).nullable().optional(),
coverImage: z.string().url().nullable().optional(),
status: z.enum(['ACTIVE', 'INACTIVE', 'ARCHIVED']).optional()
});
export type TUpdateProgram = z.infer<typeof ZUpdateProgram>;
export type TUpdateCohort = z.infer<typeof ZUpdateCohort>;
export const ZAddCourseToProgram = z.object({
export const ZAddCourseToCohort = z.object({
courseId: z.string().uuid()
});
export type TAddCourseToProgram = z.infer<typeof ZAddCourseToProgram>;
export type TAddCourseToCohort = z.infer<typeof ZAddCourseToCohort>;
export const ZAddProgramMembers = z.object({
export const ZAddCohortMembers = z.object({
members: z
.array(
z.object({
@@ -36,26 +36,26 @@ export const ZAddProgramMembers = z.object({
message: 'Each member must include a profileId or email'
})
});
export type TAddProgramMembers = z.infer<typeof ZAddProgramMembers>;
export type TAddCohortMembers = z.infer<typeof ZAddCohortMembers>;
export const ZUpdateProgramMember = z.object({
export const ZUpdateCohortMember = z.object({
roleId: z.union([z.literal(ROLE.TUTOR), z.literal(ROLE.STUDENT)])
});
export type TUpdateProgramMember = z.infer<typeof ZUpdateProgramMember>;
export type TUpdateCohortMember = z.infer<typeof ZUpdateCohortMember>;
export const ZCreateProgramNewsfeed = z.object({
export const ZCreateCohortNewsfeed = z.object({
content: z.string().min(1),
isPinned: z.boolean().optional()
});
export type TCreateProgramNewsfeed = z.infer<typeof ZCreateProgramNewsfeed>;
export type TCreateCohortNewsfeed = z.infer<typeof ZCreateCohortNewsfeed>;
export const ZUpdateProgramNewsfeed = z.object({
export const ZUpdateCohortNewsfeed = z.object({
content: z.string().min(1).optional(),
isPinned: z.boolean().optional()
});
export type TUpdateProgramNewsfeed = z.infer<typeof ZUpdateProgramNewsfeed>;
export type TUpdateCohortNewsfeed = z.infer<typeof ZUpdateCohortNewsfeed>;
export const ZUpdateProgramReaction = z.object({
export const ZUpdateCohortReaction = z.object({
reaction: z.object({
clap: z.array(z.string()),
smile: z.array(z.string()),
@@ -63,29 +63,29 @@ export const ZUpdateProgramReaction = z.object({
thumbsdown: z.array(z.string())
})
});
export type TUpdateProgramReaction = z.infer<typeof ZUpdateProgramReaction>;
export type TUpdateCohortReaction = z.infer<typeof ZUpdateCohortReaction>;
export const ZCreateProgramNewsfeedComment = z.object({
export const ZCreateCohortNewsfeedComment = z.object({
content: z.string().min(1)
});
export type TCreateProgramNewsfeedComment = z.infer<typeof ZCreateProgramNewsfeedComment>;
export type TCreateCohortNewsfeedComment = z.infer<typeof ZCreateCohortNewsfeedComment>;
export const ZInviteStudentsToProgram = z.object({
export const ZInviteStudentsToCohort = z.object({
recipientCsv: z.string().min(1).max(25000),
sendEmail: z.boolean().default(true)
});
export type TInviteStudentsToProgram = z.infer<typeof ZInviteStudentsToProgram>;
export type TInviteStudentsToCohort = z.infer<typeof ZInviteStudentsToCohort>;
export const ZAssignExistingStudentsToProgram = z.object({
export const ZAssignExistingStudentsToCohort = z.object({
profileIds: z.array(z.string().uuid()).min(1).max(500),
sendEmail: z.boolean().default(true)
});
export type TAssignExistingStudentsToProgram = z.infer<typeof ZAssignExistingStudentsToProgram>;
export type TAssignExistingStudentsToCohort = z.infer<typeof ZAssignExistingStudentsToCohort>;
// ─── Program Goals ────────────────────────────────────────────────────────────
// ─── Cohort Goals ────────────────────────────────────────────────────────────
export const PROGRAM_GOAL_TYPES = ['complete_all', 'n_of_m', 'score', 'pass_rate', 'readiness'] as const;
export const PROGRAM_GOAL_DEADLINE_KINDS = ['absolute', 'relative_to_join', 'recurring', 'none'] as const;
export const COHORT_GOAL_TYPES = ['complete_all', 'n_of_m', 'score', 'pass_rate', 'readiness'] as const;
export const COHORT_GOAL_DEADLINE_KINDS = ['absolute', 'relative_to_join', 'recurring', 'none'] as const;
const goalCommonFields = {
title: z.string().min(1).max(255),
@@ -95,7 +95,7 @@ const goalCommonFields = {
};
const goalDeadlineFields = {
deadlineKind: z.enum(PROGRAM_GOAL_DEADLINE_KINDS).default('none'),
deadlineKind: z.enum(COHORT_GOAL_DEADLINE_KINDS).default('none'),
deadlineDate: z.string().datetime().nullable().optional(),
relativeDays: z.number().int().min(1).max(3650).nullable().optional(),
recurringMonths: z.number().int().min(1).max(120).nullable().optional()
@@ -106,9 +106,9 @@ const goalDeadlineFields = {
* discriminated union) so the form layer can update fields incrementally; the
* service layer enforces the per-type required fields.
*/
export const ZCreateProgramGoal = z
export const ZCreateCohortGoal = z
.object({
type: z.enum(PROGRAM_GOAL_TYPES),
type: z.enum(COHORT_GOAL_TYPES),
requiredCount: z.number().int().min(1).max(50).nullable().optional(),
scoreThreshold: z.number().int().min(0).max(100).nullable().optional(),
teamPassRateThreshold: z.number().int().min(0).max(100).nullable().optional(),
@@ -160,11 +160,11 @@ export const ZCreateProgramGoal = z
path: ['deadlineKind']
});
export type TCreateProgramGoal = z.infer<typeof ZCreateProgramGoal>;
export type TCreateCohortGoal = z.infer<typeof ZCreateCohortGoal>;
export const ZUpdateProgramGoal = z
export const ZUpdateCohortGoal = z
.object({
type: z.enum(PROGRAM_GOAL_TYPES).optional(),
type: z.enum(COHORT_GOAL_TYPES).optional(),
title: z.string().min(1).max(255).optional(),
description: z.string().max(2000).nullable().optional(),
courseIds: z.array(z.string().uuid()).max(50).optional(),
@@ -172,7 +172,7 @@ export const ZUpdateProgramGoal = z
scoreThreshold: z.number().int().min(0).max(100).nullable().optional(),
teamPassRateThreshold: z.number().int().min(0).max(100).nullable().optional(),
reminderDaysBefore: z.array(z.number().int().min(0).max(365)).max(5).optional(),
deadlineKind: z.enum(PROGRAM_GOAL_DEADLINE_KINDS).optional(),
deadlineKind: z.enum(COHORT_GOAL_DEADLINE_KINDS).optional(),
deadlineDate: z.string().datetime().nullable().optional(),
relativeDays: z.number().int().min(1).max(3650).nullable().optional(),
recurringMonths: z.number().int().min(1).max(120).nullable().optional(),
@@ -182,4 +182,4 @@ export const ZUpdateProgramGoal = z
message: 'No fields to update'
});
export type TUpdateProgramGoal = z.infer<typeof ZUpdateProgramGoal>;
export type TUpdateCohortGoal = z.infer<typeof ZUpdateCohortGoal>;
@@ -0,0 +1 @@
export * from './cohort';
+1 -1
View File
@@ -18,6 +18,6 @@ export * from './public-api';
export * from './submission';
export * from './dash';
export * from './tag';
export * from './program';
export * from './cohort';
export * from './widget';
export * from './jobs';
@@ -18,9 +18,9 @@ export type TGetAudienceQuery = z.infer<typeof ZGetAudienceQuery>;
export const ZImportAudienceMembers = z.object({
recipientCsv: z.string().max(25000),
courseIds: z.array(z.string().uuid()).optional(),
programIds: z.array(z.string().uuid()).optional(),
cohortIds: z.array(z.string().uuid()).optional(),
allCourses: z.boolean().optional().default(false),
allPrograms: z.boolean().optional().default(false),
allCohorts: z.boolean().optional().default(false),
sendEmail: z.boolean().default(true)
});
@@ -30,11 +30,11 @@ export const ZAssignAudienceCourses = z
.object({
profileIds: z.array(z.uuid()).min(1).max(500),
courseIds: z.array(z.uuid()).optional(),
programIds: z.array(z.uuid()).optional(),
cohortIds: z.array(z.uuid()).optional(),
sendEmail: z.boolean().default(true)
})
.refine((data) => (data.courseIds?.length ?? 0) > 0 || (data.programIds?.length ?? 0) > 0, {
message: 'At least one course or program must be selected',
.refine((data) => (data.courseIds?.length ?? 0) > 0 || (data.cohortIds?.length ?? 0) > 0, {
message: 'At least one course or cohort must be selected',
path: ['courseIds']
});
@@ -1 +0,0 @@
export * from './program';