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
@@ -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';
|
||||
Reference in New Issue
Block a user