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:
co-authored by
Rotimi Best
Cursor Agent
parent
fe9f8c441f
commit
ec61bfe7d9
@@ -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',
|
||||
|
||||
+33
-33
@@ -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';
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user