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
+730
View File
@@ -0,0 +1,730 @@
import * as z from 'zod';
import {
ZAddCourseToCohort,
ZAddCohortMembers,
ZAssignExistingStudentsToCohort,
ZCreateCohort,
ZCreateCohortGoal,
ZCreateCohortNewsfeed,
ZCreateCohortNewsfeedComment,
ZInviteStudentsToCohort,
ZUpdateCohort,
ZUpdateCohortGoal,
ZUpdateCohortMember,
ZUpdateCohortNewsfeed,
ZUpdateCohortReaction
} from '@cio/utils/validation/cohort';
import {
addCourseToCohortService,
addCohortMembers,
createCohort,
createCohortNewsfeedCommentService,
createCohortNewsfeedService,
deleteCohort,
removeCohortMemberService,
deleteCohortNewsfeedCommentService,
deleteCohortNewsfeedService,
getEnrolledCohorts,
getCohort,
listOrgCohorts,
listCohortCourses,
listCohortMembers,
listCohortNewsfeed,
listCohortNewsfeedComments,
removeCourseFromCohortService,
updateCohort,
updateCohortMemberService,
updateCohortNewsfeedReactionService,
updateCohortNewsfeedService
} from '@api/services/cohort/cohort';
import { assignExistingStudentsToCohort, inviteStudentsToCohort } from '@api/services/cohort/invite';
import {
archiveGoal,
createGoal,
evaluateGoal,
evaluateCohortGoals,
getGoal,
getMyGoals,
getOrgGoalsOverview,
listGoals,
removeGoal,
updateGoal
} from '@api/services/cohort/goal';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { cohortMemberMiddleware } from '@api/middlewares/cohort-member';
import { cohortTeamMemberMiddleware } from '@api/middlewares/cohort-team-member';
import { cohortNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/cohort-newsfeed-comment-author-or-team';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
const ZCohortParam = z.object({ cohortId: z.string().uuid() });
const ZMemberParam = z.object({ cohortId: z.string().uuid(), memberId: z.string().uuid() });
const ZCourseParam = z.object({ cohortId: z.string().uuid(), courseId: z.string().uuid() });
const ZFeedParam = z.object({ cohortId: z.string().uuid(), feedId: z.string().uuid() });
const ZCommentParam = z.object({
cohortId: z.string().uuid(),
feedId: z.string().uuid(),
commentId: z.coerce.number().int()
});
const ZGoalParam = z.object({ cohortId: z.string().uuid(), goalId: z.string().uuid() });
const ZListQuery = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(50).default(10)
});
const ZOrgQuery = z.object({ organizationId: z.string().uuid() });
export const cohortRouter = new Hono()
// ── Enrolled Cohorts (LMS) ─── must be before /:cohortId ───────────────
/**
* GET /cohort/enrolled
* Get programs the current user is enrolled in
*/
.get('/enrolled', authMiddleware, async (c) => {
try {
const user = c.get('user')!;
const programs = await getEnrolledCohorts(user.id);
return c.json({ success: true, data: programs }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get enrolled cohorts');
}
})
// ── Programs ──────────────────────────────────────────────────────────────
/**
* GET /cohort?organizationId=...
* List all programs for an org
*/
.get('/', authMiddleware, zValidator('query', ZOrgQuery), async (c) => {
try {
const { organizationId } = c.req.valid('query');
const programs = await listOrgCohorts(organizationId);
return c.json({ success: true, data: programs }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list cohorts');
}
})
/**
* POST /cohort
* Create a new program
*/
.post(
'/',
authMiddleware,
zValidator('json', ZCreateCohort.extend({ organizationId: z.string().uuid() })),
async (c) => {
try {
const user = c.get('user')!;
const { organizationId, ...data } = c.req.valid('json');
const cohort = await createCohort(organizationId, user.id, data);
return c.json({ success: true, data: cohort }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create cohort');
}
}
)
/**
* GET /cohort/:cohortId
* Get a cohort by ID
*/
.get('/:cohortId', authMiddleware, cohortMemberMiddleware, zValidator('param', ZCohortParam), async (c) => {
try {
const { cohortId } = c.req.valid('param');
const cohort = await getCohort(cohortId);
return c.json({ success: true, data: cohort }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get cohort');
}
})
/**
* PUT /cohort/:cohortId
* Update a cohort
*/
.put(
'/:cohortId',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZCohortParam),
zValidator('json', ZUpdateCohort),
async (c) => {
try {
const { cohortId } = c.req.valid('param');
const data = c.req.valid('json');
const cohort = await updateCohort(cohortId, data);
return c.json({ success: true, data: cohort }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update cohort');
}
}
)
/**
* DELETE /cohort/:cohortId
* Delete a cohort
*/
.delete('/:cohortId', authMiddleware, cohortTeamMemberMiddleware, zValidator('param', ZCohortParam), async (c) => {
try {
const { cohortId } = c.req.valid('param');
const cohort = await deleteCohort(cohortId);
return c.json({ success: true, data: cohort }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete cohort');
}
})
// ── Members ───────────────────────────────────────────────────────────────
/**
* GET /cohort/:cohortId/members
* List cohort members
*/
.get('/:cohortId/members', authMiddleware, cohortMemberMiddleware, zValidator('param', ZCohortParam), async (c) => {
try {
const { cohortId } = c.req.valid('param');
const members = await listCohortMembers(cohortId);
return c.json({ success: true, data: members }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list cohort members');
}
})
/**
* POST /cohort/:cohortId/members
* Add members to a cohort
*/
.post(
'/:cohortId/members',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZCohortParam),
zValidator('json', ZAddCohortMembers),
async (c) => {
try {
const { cohortId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await addCohortMembers(cohortId, data);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to add cohort members');
}
}
)
/**
* PUT /cohort/:cohortId/members/:memberId
* Update a cohort member's role
*/
.put(
'/:cohortId/members/:memberId',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZMemberParam),
zValidator('json', ZUpdateCohortMember),
async (c) => {
try {
const { cohortId, memberId } = c.req.valid('param');
const data = c.req.valid('json');
const member = await updateCohortMemberService(cohortId, memberId, data);
return c.json({ success: true, data: member }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update cohort member');
}
}
)
/**
* DELETE /cohort/:cohortId/members/:memberId
* Remove a member from a cohort
*/
.delete(
'/:cohortId/members/:memberId',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZMemberParam),
async (c) => {
try {
const { cohortId, memberId } = c.req.valid('param');
const member = await removeCohortMemberService(cohortId, memberId);
return c.json({ success: true, data: member }, 200);
} catch (error) {
return handleError(c, error, 'Failed to remove program member');
}
}
)
// ── Invite ────────────────────────────────────────────────────────────────
/**
* POST /cohort/:cohortId/invite
* Invite new students to a cohort by CSV emails. Issues org invites
* pre-tagged with this cohort so accept auto-enrolls them.
* Gated by cohort-team-member middleware — Cohort ADMINs/TUTORs can
* invite into their own program without org-admin rights.
*/
.post(
'/:cohortId/invite',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZCohortParam),
zValidator('json', ZInviteStudentsToCohort),
async (c) => {
try {
const user = c.get('user')!;
const { cohortId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await inviteStudentsToCohort(cohortId, data, user.id);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to invite students to program');
}
}
)
/**
* POST /cohort/:cohortId/invite/assign
* Assign existing org-audience student profiles to a cohort.
*/
.post(
'/:cohortId/invite/assign',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZCohortParam),
zValidator('json', ZAssignExistingStudentsToCohort),
async (c) => {
try {
const { cohortId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await assignExistingStudentsToCohort(cohortId, data);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to assign students to program');
}
}
)
// ── Courses ───────────────────────────────────────────────────────────────
/**
* GET /cohort/:cohortId/courses
* List courses in a cohort
*/
.get('/:cohortId/courses', authMiddleware, cohortMemberMiddleware, zValidator('param', ZCohortParam), async (c) => {
try {
const user = c.get('user')!;
const { cohortId } = c.req.valid('param');
const courses = await listCohortCourses(cohortId, user.id);
return c.json({ success: true, data: courses }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list cohort courses');
}
})
/**
* POST /cohort/:cohortId/courses
* Add a course to a cohort
*/
.post(
'/:cohortId/courses',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZCohortParam),
zValidator('json', ZAddCourseToCohort),
async (c) => {
try {
const { cohortId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await addCourseToCohortService(cohortId, data);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to add course to program');
}
}
)
/**
* DELETE /cohort/:cohortId/courses/:courseId
* Remove a course from a cohort
*/
.delete(
'/:cohortId/courses/:courseId',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZCourseParam),
async (c) => {
try {
const { cohortId, courseId } = c.req.valid('param');
const result = await removeCourseFromCohortService(cohortId, courseId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to remove course from program');
}
}
)
// ── Newsfeed ──────────────────────────────────────────────────────────────
/**
* GET /cohort/:cohortId/newsfeed
* List newsfeed for a cohort (paginated)
*/
.get(
'/:cohortId/newsfeed',
authMiddleware,
cohortMemberMiddleware,
zValidator('param', ZCohortParam),
zValidator('query', ZListQuery),
async (c) => {
try {
const { cohortId } = c.req.valid('param');
const { cursor, limit } = c.req.valid('query');
const result = await listCohortNewsfeed(cohortId, { cursor, limit });
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list cohort newsfeed');
}
}
)
/**
* POST /cohort/:cohortId/newsfeed
* Create a newsfeed post
*/
.post(
'/:cohortId/newsfeed',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZCohortParam),
zValidator('json', ZCreateCohortNewsfeed),
async (c) => {
try {
const user = c.get('user')!;
const { cohortId } = c.req.valid('param');
const data = c.req.valid('json');
const feed = await createCohortNewsfeedService(cohortId, user.id, data);
return c.json({ success: true, data: feed }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create cohort newsfeed post');
}
}
)
/**
* PUT /cohort/:cohortId/newsfeed/:feedId
* Update a newsfeed post
*/
.put(
'/:cohortId/newsfeed/:feedId',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZFeedParam),
zValidator('json', ZUpdateCohortNewsfeed),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const data = c.req.valid('json');
const feed = await updateCohortNewsfeedService(feedId, data);
return c.json({ success: true, data: feed }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update cohort newsfeed post');
}
}
)
/**
* PUT /cohort/:cohortId/newsfeed/:feedId/react
* Update reaction on a newsfeed post
*/
.put(
'/:cohortId/newsfeed/:feedId/react',
authMiddleware,
cohortMemberMiddleware,
zValidator('param', ZFeedParam),
zValidator('json', ZUpdateCohortReaction),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const data = c.req.valid('json');
const feed = await updateCohortNewsfeedReactionService(feedId, data);
return c.json({ success: true, data: feed }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update cohort newsfeed reaction');
}
}
)
/**
* DELETE /cohort/:cohortId/newsfeed/:feedId
* Delete a newsfeed post
*/
.delete(
'/:cohortId/newsfeed/:feedId',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZFeedParam),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const feed = await deleteCohortNewsfeedService(feedId);
return c.json({ success: true, data: feed }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete cohort newsfeed post');
}
}
)
/**
* GET /cohort/:cohortId/newsfeed/:feedId/comments
* List comments on a newsfeed post
*/
.get(
'/:cohortId/newsfeed/:feedId/comments',
authMiddleware,
cohortMemberMiddleware,
zValidator('param', ZFeedParam),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const comments = await listCohortNewsfeedComments(feedId);
return c.json({ success: true, data: comments }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list cohort newsfeed comments');
}
}
)
/**
* POST /cohort/:cohortId/newsfeed/:feedId/comment
* Create a comment on a newsfeed post
*/
.post(
'/:cohortId/newsfeed/:feedId/comment',
authMiddleware,
cohortMemberMiddleware,
zValidator('param', ZFeedParam),
zValidator('json', ZCreateCohortNewsfeedComment),
async (c) => {
try {
const user = c.get('user')!;
const { feedId } = c.req.valid('param');
const data = c.req.valid('json');
const comment = await createCohortNewsfeedCommentService(feedId, user.id, data);
return c.json({ success: true, data: comment }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create cohort newsfeed comment');
}
}
)
/**
* DELETE /cohort/:cohortId/newsfeed/:feedId/comment/:commentId
* Delete a comment on a newsfeed post
*/
.delete(
'/:cohortId/newsfeed/:feedId/comment/:commentId',
authMiddleware,
cohortNewsfeedCommentAuthorOrTeamMiddleware,
zValidator('param', ZCommentParam),
async (c) => {
try {
const { commentId } = c.req.valid('param');
const comment = await deleteCohortNewsfeedCommentService(commentId);
return c.json({ success: true, data: comment }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete cohort newsfeed comment');
}
}
)
// ── Goals ─────────────────────────────────────────────────────────────────
/**
* GET /cohort/my/goals
* Goal assignments for the current authenticated user across all their programs.
* Used by the LMS "Your Goals" widget.
*/
.get('/my/goals', authMiddleware, async (c) => {
try {
const user = c.get('user')!;
const goals = await getMyGoals(user.id);
return c.json({ success: true, data: goals }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load goals');
}
})
/**
* GET /cohort/goals/overview?organizationId=...
* Cross-cohort goal roll-up for the org owner. One entry per active goal
* with per-status learner counts, scoped to the org via header + query.
*/
.get('/goals/overview', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZOrgQuery), async (c) => {
try {
const { organizationId } = c.req.valid('query');
const overview = await getOrgGoalsOverview(organizationId);
return c.json({ success: true, data: overview }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load goals overview');
}
})
/**
* GET /cohort/:cohortId/goals
*/
.get('/:cohortId/goals', authMiddleware, cohortMemberMiddleware, zValidator('param', ZCohortParam), async (c) => {
try {
const { cohortId } = c.req.valid('param');
const goals = await listGoals(cohortId);
return c.json({ success: true, data: goals }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list cohort goals');
}
})
/**
* POST /cohort/:cohortId/goals
*/
.post(
'/:cohortId/goals',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZCohortParam),
zValidator('json', ZCreateCohortGoal),
async (c) => {
try {
const user = c.get('user')!;
const { cohortId } = c.req.valid('param');
const data = c.req.valid('json');
const goal = await createGoal(cohortId, user.id, data);
return c.json({ success: true, data: goal }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create cohort goal');
}
}
)
/**
* GET /cohort/:cohortId/goals/:goalId
*/
.get(
'/:cohortId/goals/:goalId',
authMiddleware,
cohortMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const goal = await getGoal(goalId);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get cohort goal');
}
}
)
/**
* PUT /cohort/:cohortId/goals/:goalId
*/
.put(
'/:cohortId/goals/:goalId',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZGoalParam),
zValidator('json', ZUpdateCohortGoal),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const data = c.req.valid('json');
const goal = await updateGoal(goalId, data);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update cohort goal');
}
}
)
/**
* DELETE /cohort/:cohortId/goals/:goalId
* Hard delete (vs. archive). Use PUT with status=archived to preserve history.
*/
.delete(
'/:cohortId/goals/:goalId',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const goal = await removeGoal(goalId);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete cohort goal');
}
}
)
/**
* POST /cohort/:cohortId/goals/:goalId/archive
*/
.post(
'/:cohortId/goals/:goalId/archive',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const goal = await archiveGoal(goalId);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to archive cohort goal');
}
}
)
/**
* POST /cohort/:cohortId/goals/:goalId/evaluate
* Force a re-evaluation of one goal's per-learner statuses.
*/
.post(
'/:cohortId/goals/:goalId/evaluate',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const result = await evaluateGoal(goalId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to evaluate cohort goal');
}
}
)
/**
* POST /cohort/:cohortId/goals/evaluate-all
* Force a re-evaluation of every active goal in the cohort.
*/
.post(
'/:cohortId/goals/evaluate-all',
authMiddleware,
cohortTeamMemberMiddleware,
zValidator('param', ZCohortParam),
async (c) => {
try {
const { cohortId } = c.req.valid('param');
const result = await evaluateCohortGoals(cohortId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to evaluate cohort goals');
}
}
);
+1
View File
@@ -0,0 +1 @@
export { cohortRouter } from './cohort';
+12 -12
View File
@@ -1,5 +1,5 @@
import { runComplianceExpiryCheck, runComplianceReminderScan } from '@api/services/course/compliance';
import { runProgramGoalEvaluationSweep, runProgramGoalReminderScan } from '@api/services/program/goal';
import { runCohortGoalEvaluationSweep, runCohortGoalReminderScan } from '@api/services/cohort/goal';
import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
@@ -47,30 +47,30 @@ export const internalComplianceRouter = new Hono()
}
})
/**
* POST /internal/compliance/evaluate-program-goals
* Daily sweep: re-evaluate every active program goal and refresh per-learner
* `program_goal_assignment` rows.
* POST /internal/compliance/evaluate-cohort-goals
* Daily sweep: re-evaluate every active cohort goal and refresh per-learner
* `cohort_goal_assignment` rows.
*/
.post('/evaluate-program-goals', apiKeyMiddleware, async (c) => {
.post('/evaluate-cohort-goals', apiKeyMiddleware, async (c) => {
try {
const data = await runProgramGoalEvaluationSweep();
const data = await runCohortGoalEvaluationSweep();
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to run program goal evaluation sweep');
return handleError(c, error, 'Failed to run cohort goal evaluation sweep');
}
})
/**
* POST /internal/compliance/send-program-goal-reminders
* Sends learner reminder emails for upcoming/overdue program goals using
* POST /internal/compliance/send-cohort-goal-reminders
* Sends learner reminder emails for upcoming/overdue cohort goals using
* each goal's `reminderDaysBefore` cadence.
*/
.post('/send-program-goal-reminders', apiKeyMiddleware, async (c) => {
.post('/send-cohort-goal-reminders', apiKeyMiddleware, async (c) => {
try {
const data = await runProgramGoalReminderScan();
const data = await runCohortGoalReminderScan();
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to run program goal reminder scan');
return handleError(c, error, 'Failed to run cohort goal reminder scan');
}
});
-1
View File
@@ -1 +0,0 @@
export { programRouter } from './program';
-742
View File
@@ -1,742 +0,0 @@
import * as z from 'zod';
import {
ZAddCourseToProgram,
ZAddProgramMembers,
ZAssignExistingStudentsToProgram,
ZCreateProgram,
ZCreateProgramGoal,
ZCreateProgramNewsfeed,
ZCreateProgramNewsfeedComment,
ZInviteStudentsToProgram,
ZUpdateProgram,
ZUpdateProgramGoal,
ZUpdateProgramMember,
ZUpdateProgramNewsfeed,
ZUpdateProgramReaction
} from '@cio/utils/validation/program';
import {
addCourseToProgramService,
addProgramMembers,
createProgram,
createProgramNewsfeedCommentService,
createProgramNewsfeedService,
deleteProgram,
removeProgramMemberService,
deleteProgramNewsfeedCommentService,
deleteProgramNewsfeedService,
getEnrolledPrograms,
getProgram,
listOrgPrograms,
listProgramCourses,
listProgramMembers,
listProgramNewsfeed,
listProgramNewsfeedComments,
removeCourseFromProgramService,
updateProgram,
updateProgramMemberService,
updateProgramNewsfeedReactionService,
updateProgramNewsfeedService
} from '@api/services/program/program';
import { assignExistingStudentsToProgram, inviteStudentsToProgram } from '@api/services/program/invite';
import {
archiveGoal,
createGoal,
evaluateGoal,
evaluateProgramGoals,
getGoal,
getMyGoals,
getOrgGoalsOverview,
listGoals,
removeGoal,
updateGoal
} from '@api/services/program/goal';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { programMemberMiddleware } from '@api/middlewares/program-member';
import { programTeamMemberMiddleware } from '@api/middlewares/program-team-member';
import { programNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/program-newsfeed-comment-author-or-team';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
const ZProgramParam = z.object({ programId: z.string().uuid() });
const ZMemberParam = z.object({ programId: z.string().uuid(), memberId: z.string().uuid() });
const ZCourseParam = z.object({ programId: z.string().uuid(), courseId: z.string().uuid() });
const ZFeedParam = z.object({ programId: z.string().uuid(), feedId: z.string().uuid() });
const ZCommentParam = z.object({
programId: z.string().uuid(),
feedId: z.string().uuid(),
commentId: z.coerce.number().int()
});
const ZGoalParam = z.object({ programId: z.string().uuid(), goalId: z.string().uuid() });
const ZListQuery = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(50).default(10)
});
const ZOrgQuery = z.object({ organizationId: z.string().uuid() });
export const programRouter = new Hono()
// ── Enrolled Programs (LMS) ─── must be before /:programId ───────────────
/**
* GET /program/enrolled
* Get programs the current user is enrolled in
*/
.get('/enrolled', authMiddleware, async (c) => {
try {
const user = c.get('user')!;
const programs = await getEnrolledPrograms(user.id);
return c.json({ success: true, data: programs }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get enrolled programs');
}
})
// ── Programs ──────────────────────────────────────────────────────────────
/**
* GET /program?organizationId=...
* List all programs for an org
*/
.get('/', authMiddleware, zValidator('query', ZOrgQuery), async (c) => {
try {
const { organizationId } = c.req.valid('query');
const programs = await listOrgPrograms(organizationId);
return c.json({ success: true, data: programs }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list programs');
}
})
/**
* POST /program
* Create a new program
*/
.post(
'/',
authMiddleware,
zValidator('json', ZCreateProgram.extend({ organizationId: z.string().uuid() })),
async (c) => {
try {
const user = c.get('user')!;
const { organizationId, ...data } = c.req.valid('json');
const program = await createProgram(organizationId, user.id, data);
return c.json({ success: true, data: program }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create program');
}
}
)
/**
* GET /program/:programId
* Get a program by ID
*/
.get('/:programId', authMiddleware, programMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
try {
const { programId } = c.req.valid('param');
const program = await getProgram(programId);
return c.json({ success: true, data: program }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get program');
}
})
/**
* PUT /program/:programId
* Update a program
*/
.put(
'/:programId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZUpdateProgram),
async (c) => {
try {
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const program = await updateProgram(programId, data);
return c.json({ success: true, data: program }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program');
}
}
)
/**
* DELETE /program/:programId
* Delete a program
*/
.delete('/:programId', authMiddleware, programTeamMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
try {
const { programId } = c.req.valid('param');
const program = await deleteProgram(programId);
return c.json({ success: true, data: program }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete program');
}
})
// ── Members ───────────────────────────────────────────────────────────────
/**
* GET /program/:programId/members
* List program members
*/
.get(
'/:programId/members',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZProgramParam),
async (c) => {
try {
const { programId } = c.req.valid('param');
const members = await listProgramMembers(programId);
return c.json({ success: true, data: members }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program members');
}
}
)
/**
* POST /program/:programId/members
* Add members to a program
*/
.post(
'/:programId/members',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZAddProgramMembers),
async (c) => {
try {
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await addProgramMembers(programId, data);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to add program members');
}
}
)
/**
* PUT /program/:programId/members/:memberId
* Update a program member's role
*/
.put(
'/:programId/members/:memberId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZMemberParam),
zValidator('json', ZUpdateProgramMember),
async (c) => {
try {
const { programId, memberId } = c.req.valid('param');
const data = c.req.valid('json');
const member = await updateProgramMemberService(programId, memberId, data);
return c.json({ success: true, data: member }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program member');
}
}
)
/**
* DELETE /program/:programId/members/:memberId
* Remove a member from a program
*/
.delete(
'/:programId/members/:memberId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZMemberParam),
async (c) => {
try {
const { programId, memberId } = c.req.valid('param');
const member = await removeProgramMemberService(programId, memberId);
return c.json({ success: true, data: member }, 200);
} catch (error) {
return handleError(c, error, 'Failed to remove program member');
}
}
)
// ── Invite ────────────────────────────────────────────────────────────────
/**
* POST /program/:programId/invite
* Invite new students to a program by CSV emails. Issues org invites
* pre-tagged with this program so accept auto-enrolls them.
* Gated by program-team-member middleware — Program ADMINs/TUTORs can
* invite into their own program without org-admin rights.
*/
.post(
'/:programId/invite',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZInviteStudentsToProgram),
async (c) => {
try {
const user = c.get('user')!;
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await inviteStudentsToProgram(programId, data, user.id);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to invite students to program');
}
}
)
/**
* POST /program/:programId/invite/assign
* Assign existing org-audience student profiles to a program.
*/
.post(
'/:programId/invite/assign',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZAssignExistingStudentsToProgram),
async (c) => {
try {
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await assignExistingStudentsToProgram(programId, data);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to assign students to program');
}
}
)
// ── Courses ───────────────────────────────────────────────────────────────
/**
* GET /program/:programId/courses
* List courses in a program
*/
.get(
'/:programId/courses',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZProgramParam),
async (c) => {
try {
const user = c.get('user')!;
const { programId } = c.req.valid('param');
const courses = await listProgramCourses(programId, user.id);
return c.json({ success: true, data: courses }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program courses');
}
}
)
/**
* POST /program/:programId/courses
* Add a course to a program
*/
.post(
'/:programId/courses',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZAddCourseToProgram),
async (c) => {
try {
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await addCourseToProgramService(programId, data);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to add course to program');
}
}
)
/**
* DELETE /program/:programId/courses/:courseId
* Remove a course from a program
*/
.delete(
'/:programId/courses/:courseId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZCourseParam),
async (c) => {
try {
const { programId, courseId } = c.req.valid('param');
const result = await removeCourseFromProgramService(programId, courseId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to remove course from program');
}
}
)
// ── Newsfeed ──────────────────────────────────────────────────────────────
/**
* GET /program/:programId/newsfeed
* List newsfeed for a program (paginated)
*/
.get(
'/:programId/newsfeed',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('query', ZListQuery),
async (c) => {
try {
const { programId } = c.req.valid('param');
const { cursor, limit } = c.req.valid('query');
const result = await listProgramNewsfeed(programId, { cursor, limit });
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program newsfeed');
}
}
)
/**
* POST /program/:programId/newsfeed
* Create a newsfeed post
*/
.post(
'/:programId/newsfeed',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZCreateProgramNewsfeed),
async (c) => {
try {
const user = c.get('user')!;
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const feed = await createProgramNewsfeedService(programId, user.id, data);
return c.json({ success: true, data: feed }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create program newsfeed post');
}
}
)
/**
* PUT /program/:programId/newsfeed/:feedId
* Update a newsfeed post
*/
.put(
'/:programId/newsfeed/:feedId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZFeedParam),
zValidator('json', ZUpdateProgramNewsfeed),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const data = c.req.valid('json');
const feed = await updateProgramNewsfeedService(feedId, data);
return c.json({ success: true, data: feed }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program newsfeed post');
}
}
)
/**
* PUT /program/:programId/newsfeed/:feedId/react
* Update reaction on a newsfeed post
*/
.put(
'/:programId/newsfeed/:feedId/react',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZFeedParam),
zValidator('json', ZUpdateProgramReaction),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const data = c.req.valid('json');
const feed = await updateProgramNewsfeedReactionService(feedId, data);
return c.json({ success: true, data: feed }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program newsfeed reaction');
}
}
)
/**
* DELETE /program/:programId/newsfeed/:feedId
* Delete a newsfeed post
*/
.delete(
'/:programId/newsfeed/:feedId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZFeedParam),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const feed = await deleteProgramNewsfeedService(feedId);
return c.json({ success: true, data: feed }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete program newsfeed post');
}
}
)
/**
* GET /program/:programId/newsfeed/:feedId/comments
* List comments on a newsfeed post
*/
.get(
'/:programId/newsfeed/:feedId/comments',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZFeedParam),
async (c) => {
try {
const { feedId } = c.req.valid('param');
const comments = await listProgramNewsfeedComments(feedId);
return c.json({ success: true, data: comments }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program newsfeed comments');
}
}
)
/**
* POST /program/:programId/newsfeed/:feedId/comment
* Create a comment on a newsfeed post
*/
.post(
'/:programId/newsfeed/:feedId/comment',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZFeedParam),
zValidator('json', ZCreateProgramNewsfeedComment),
async (c) => {
try {
const user = c.get('user')!;
const { feedId } = c.req.valid('param');
const data = c.req.valid('json');
const comment = await createProgramNewsfeedCommentService(feedId, user.id, data);
return c.json({ success: true, data: comment }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create program newsfeed comment');
}
}
)
/**
* DELETE /program/:programId/newsfeed/:feedId/comment/:commentId
* Delete a comment on a newsfeed post
*/
.delete(
'/:programId/newsfeed/:feedId/comment/:commentId',
authMiddleware,
programNewsfeedCommentAuthorOrTeamMiddleware,
zValidator('param', ZCommentParam),
async (c) => {
try {
const { commentId } = c.req.valid('param');
const comment = await deleteProgramNewsfeedCommentService(commentId);
return c.json({ success: true, data: comment }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete program newsfeed comment');
}
}
)
// ── Goals ─────────────────────────────────────────────────────────────────
/**
* GET /program/my/goals
* Goal assignments for the current authenticated user across all their programs.
* Used by the LMS "Your Goals" widget.
*/
.get('/my/goals', authMiddleware, async (c) => {
try {
const user = c.get('user')!;
const goals = await getMyGoals(user.id);
return c.json({ success: true, data: goals }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load goals');
}
})
/**
* GET /program/goals/overview?organizationId=...
* Cross-program goal roll-up for the org owner. One entry per active goal
* with per-status learner counts, scoped to the org via header + query.
*/
.get('/goals/overview', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZOrgQuery), async (c) => {
try {
const { organizationId } = c.req.valid('query');
const overview = await getOrgGoalsOverview(organizationId);
return c.json({ success: true, data: overview }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load goals overview');
}
})
/**
* GET /program/:programId/goals
*/
.get('/:programId/goals', authMiddleware, programMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
try {
const { programId } = c.req.valid('param');
const goals = await listGoals(programId);
return c.json({ success: true, data: goals }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program goals');
}
})
/**
* POST /program/:programId/goals
*/
.post(
'/:programId/goals',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZCreateProgramGoal),
async (c) => {
try {
const user = c.get('user')!;
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const goal = await createGoal(programId, user.id, data);
return c.json({ success: true, data: goal }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create program goal');
}
}
)
/**
* GET /program/:programId/goals/:goalId
*/
.get(
'/:programId/goals/:goalId',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const goal = await getGoal(goalId);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get program goal');
}
}
)
/**
* PUT /program/:programId/goals/:goalId
*/
.put(
'/:programId/goals/:goalId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZGoalParam),
zValidator('json', ZUpdateProgramGoal),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const data = c.req.valid('json');
const goal = await updateGoal(goalId, data);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program goal');
}
}
)
/**
* DELETE /program/:programId/goals/:goalId
* Hard delete (vs. archive). Use PUT with status=archived to preserve history.
*/
.delete(
'/:programId/goals/:goalId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const goal = await removeGoal(goalId);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete program goal');
}
}
)
/**
* POST /program/:programId/goals/:goalId/archive
*/
.post(
'/:programId/goals/:goalId/archive',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const goal = await archiveGoal(goalId);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to archive program goal');
}
}
)
/**
* POST /program/:programId/goals/:goalId/evaluate
* Force a re-evaluation of one goal's per-learner statuses.
*/
.post(
'/:programId/goals/:goalId/evaluate',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const result = await evaluateGoal(goalId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to evaluate program goal');
}
}
)
/**
* POST /program/:programId/goals/evaluate-all
* Force a re-evaluation of every active goal in the program.
*/
.post(
'/:programId/goals/evaluate-all',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
async (c) => {
try {
const { programId } = c.req.valid('param');
const result = await evaluateProgramGoals(programId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to evaluate program goals');
}
}
);