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
@@ -0,0 +1,185 @@
import * as z from 'zod';
import { ROLE } from '@cio/utils/constants';
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 TCreateCohort = z.infer<typeof ZCreateCohort>;
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 TUpdateCohort = z.infer<typeof ZUpdateCohort>;
export const ZAddCourseToCohort = z.object({
courseId: z.string().uuid()
});
export type TAddCourseToCohort = z.infer<typeof ZAddCourseToCohort>;
export const ZAddCohortMembers = z.object({
members: z
.array(
z.object({
profileId: z.string().uuid().optional(),
email: z.string().email().optional(),
name: z.string().optional(),
roleId: z.union([z.literal(ROLE.TUTOR), z.literal(ROLE.STUDENT)])
})
)
.min(1)
.refine((members) => members.every((member) => member.profileId || member.email), {
message: 'Each member must include a profileId or email'
})
});
export type TAddCohortMembers = z.infer<typeof ZAddCohortMembers>;
export const ZUpdateCohortMember = z.object({
roleId: z.union([z.literal(ROLE.TUTOR), z.literal(ROLE.STUDENT)])
});
export type TUpdateCohortMember = z.infer<typeof ZUpdateCohortMember>;
export const ZCreateCohortNewsfeed = z.object({
content: z.string().min(1),
isPinned: z.boolean().optional()
});
export type TCreateCohortNewsfeed = z.infer<typeof ZCreateCohortNewsfeed>;
export const ZUpdateCohortNewsfeed = z.object({
content: z.string().min(1).optional(),
isPinned: z.boolean().optional()
});
export type TUpdateCohortNewsfeed = z.infer<typeof ZUpdateCohortNewsfeed>;
export const ZUpdateCohortReaction = z.object({
reaction: z.object({
clap: z.array(z.string()),
smile: z.array(z.string()),
thumbsup: z.array(z.string()),
thumbsdown: z.array(z.string())
})
});
export type TUpdateCohortReaction = z.infer<typeof ZUpdateCohortReaction>;
export const ZCreateCohortNewsfeedComment = z.object({
content: z.string().min(1)
});
export type TCreateCohortNewsfeedComment = z.infer<typeof ZCreateCohortNewsfeedComment>;
export const ZInviteStudentsToCohort = z.object({
recipientCsv: z.string().min(1).max(25000),
sendEmail: z.boolean().default(true)
});
export type TInviteStudentsToCohort = z.infer<typeof ZInviteStudentsToCohort>;
export const ZAssignExistingStudentsToCohort = z.object({
profileIds: z.array(z.string().uuid()).min(1).max(500),
sendEmail: z.boolean().default(true)
});
export type TAssignExistingStudentsToCohort = z.infer<typeof ZAssignExistingStudentsToCohort>;
// ─── Cohort Goals ────────────────────────────────────────────────────────────
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),
description: z.string().max(2000).nullable().optional(),
courseIds: z.array(z.string().uuid()).max(50),
reminderDaysBefore: z.array(z.number().int().min(0).max(365)).max(5).default([7, 1])
};
const goalDeadlineFields = {
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()
};
/**
* Goal config across the five v1 types. We keep this as one schema (not a
* discriminated union) so the form layer can update fields incrementally; the
* service layer enforces the per-type required fields.
*/
export const ZCreateCohortGoal = z
.object({
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(),
...goalCommonFields,
...goalDeadlineFields
})
.refine((data) => data.courseIds.length > 0, {
message: 'At least one course is required',
path: ['courseIds']
})
.refine((data) => data.type !== 'n_of_m' || (data.requiredCount && data.requiredCount > 0), {
message: 'requiredCount is required for n_of_m goals',
path: ['requiredCount']
})
.refine((data) => data.type !== 'n_of_m' || !data.requiredCount || data.requiredCount <= data.courseIds.length, {
message: 'requiredCount cannot exceed selected courses',
path: ['requiredCount']
})
.refine((data) => data.type !== 'score' || (data.scoreThreshold !== null && data.scoreThreshold !== undefined), {
message: 'scoreThreshold is required for score goals',
path: ['scoreThreshold']
})
.refine(
(data) =>
data.type !== 'pass_rate' ||
(data.scoreThreshold !== null &&
data.scoreThreshold !== undefined &&
data.teamPassRateThreshold !== null &&
data.teamPassRateThreshold !== undefined),
{
message: 'scoreThreshold and teamPassRateThreshold are required for pass_rate goals',
path: ['teamPassRateThreshold']
}
)
.refine((data) => data.deadlineKind !== 'absolute' || !!data.deadlineDate, {
message: 'deadlineDate is required for absolute deadlines',
path: ['deadlineDate']
})
.refine((data) => data.deadlineKind !== 'relative_to_join' || !!data.relativeDays, {
message: 'relativeDays is required for relative-to-join deadlines',
path: ['relativeDays']
})
.refine((data) => data.deadlineKind !== 'recurring' || !!data.recurringMonths, {
message: 'recurringMonths is required for recurring deadlines',
path: ['recurringMonths']
})
.refine((data) => data.type !== 'readiness' || data.deadlineKind === 'none', {
message: 'Readiness goals do not support deadlines',
path: ['deadlineKind']
});
export type TCreateCohortGoal = z.infer<typeof ZCreateCohortGoal>;
export const ZUpdateCohortGoal = z
.object({
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(),
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(),
reminderDaysBefore: z.array(z.number().int().min(0).max(365)).max(5).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(),
status: z.enum(['active', 'archived']).optional()
})
.refine((data) => Object.keys(data).length > 0, {
message: 'No fields to update'
});
export type TUpdateCohortGoal = z.infer<typeof ZUpdateCohortGoal>;
@@ -0,0 +1 @@
export * from './cohort';