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
+2 -2
View File
@@ -31,7 +31,7 @@ import { organizationRouter } from '@api/routes/organization';
import { organizationSsoRouter } from '@api/routes/organization/sso';
import { organizationTokenAuthRouter } from '@api/routes/organization/token-auth';
import { prettyJSON } from 'hono/pretty-json';
import { programRouter } from '@api/routes/program';
import { cohortRouter } from '@api/routes/cohort';
import { publicCourseRouter } from '@api/routes/org-site';
import { publicWidgetsRouter } from '@api/routes/widgets';
import rateLimiter from '@api/middlewares/rate-limiter';
@@ -232,7 +232,7 @@ export const app = new Hono()
.route('/invite', inviteRouter)
.route('/org-site/course', publicCourseRouter)
.route('/public-api/v1', v1Router)
.route('/program', programRouter)
.route('/cohort', cohortRouter)
.route('/unsplash', unsplashRouter)
.route('/widgets', publicWidgetsRouter)
.route('/internal', internalRouter)
@@ -1,13 +1,13 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { isOrgAdminByProgramId, isProgramMember } from '@cio/db/queries/program';
import { isOrgAdminByCohortId, isCohortMember } from '@cio/db/queries/cohort';
/**
* Middleware to check if the authenticated user is a member of the program
* or an org admin for the program's organization.
* Middleware to check if the authenticated user is a member of the cohort
* or an org admin for the cohort's organization.
*/
export const programMemberMiddleware = async (c: Context, next: Next) => {
export const cohortMemberMiddleware = async (c: Context, next: Next) => {
try {
const user = c.get('user');
if (!user) {
@@ -21,21 +21,21 @@ export const programMemberMiddleware = async (c: Context, next: Next) => {
);
}
const programId = c.req.param('programId');
if (!programId) {
const cohortId = c.req.param('cohortId');
if (!cohortId) {
return c.json(
{
success: false,
error: 'Program ID is required',
code: 'PROGRAM_ID_REQUIRED'
code: 'COHORT_ID_REQUIRED'
},
400
);
}
const [member, isOrgAdmin] = await Promise.all([
isProgramMember(programId, user.id),
isOrgAdminByProgramId(programId, user.id)
isCohortMember(cohortId, user.id),
isOrgAdminByCohortId(cohortId, user.id)
]);
if (member || isOrgAdmin) {
@@ -45,18 +45,18 @@ export const programMemberMiddleware = async (c: Context, next: Next) => {
return c.json(
{
success: false,
error: 'You must be a member of this program to perform this action',
code: ErrorCodes.PROGRAM_FORBIDDEN
error: 'You must be a member of this cohort to perform this action',
code: ErrorCodes.COHORT_FORBIDDEN
},
403
);
} catch (error) {
console.error('Error in programMemberMiddleware:', error);
console.error('Error in cohortMemberMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify program membership',
code: ErrorCodes.PROGRAM_MEMBER_CHECK_FAILED
error: 'Failed to verify cohort membership',
code: ErrorCodes.COHORT_MEMBER_CHECK_FAILED
},
500
);
@@ -1,15 +1,15 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import {
getProgramMemberByProfileId,
getProgramMemberRole,
getProgramNewsfeedById,
getProgramNewsfeedCommentById,
isOrgAdminByProgramId
} from '@cio/db/queries/program';
getCohortMemberByProfileId,
getCohortMemberRole,
getCohortNewsfeedById,
getCohortNewsfeedCommentById,
isOrgAdminByCohortId
} from '@cio/db/queries/cohort';
import { ROLE } from '@cio/utils/constants';
export const programNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, next: Next) => {
export const cohortNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, next: Next) => {
try {
const user = c.get('user');
if (!user) {
@@ -35,44 +35,44 @@ export const programNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, n
);
}
const comment = await getProgramNewsfeedCommentById(Number(commentId));
if (!comment?.programNewsfeedId) {
const comment = await getCohortNewsfeedCommentById(Number(commentId));
if (!comment?.cohortNewsfeedId) {
return c.json(
{
success: false,
error: 'Comment not found',
code: ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND
code: ErrorCodes.COHORT_NEWSFEED_COMMENT_NOT_FOUND
},
404
);
}
const feed = await getProgramNewsfeedById(comment.programNewsfeedId);
if (!feed?.programId) {
const feed = await getCohortNewsfeedById(comment.cohortNewsfeedId);
if (!feed?.cohortId) {
return c.json(
{
success: false,
error: 'Program newsfeed item not found',
code: ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND
error: 'Cohort newsfeed item not found',
code: ErrorCodes.COHORT_NEWSFEED_NOT_FOUND
},
404
);
}
const member = await getProgramMemberByProfileId(feed.programId, user.id);
const member = await getCohortMemberByProfileId(feed.cohortId, user.id);
const isAuthor = comment.authorId === member?.id;
if (isAuthor) {
await next();
return;
}
const isOrgAdmin = await isOrgAdminByProgramId(feed.programId, user.id);
const isOrgAdmin = await isOrgAdminByCohortId(feed.cohortId, user.id);
if (isOrgAdmin) {
await next();
return;
}
const roleId = await getProgramMemberRole(feed.programId, user.id);
const roleId = await getCohortMemberRole(feed.cohortId, user.id);
const isTeamMember = roleId === ROLE.ADMIN || roleId === ROLE.TUTOR;
if (isTeamMember) {
await next();
@@ -88,7 +88,7 @@ export const programNewsfeedCommentAuthorOrTeamMiddleware = async (c: Context, n
403
);
} catch (error) {
console.error('Error in programNewsfeedCommentAuthorOrTeamMiddleware:', error);
console.error('Error in cohortNewsfeedCommentAuthorOrTeamMiddleware:', error);
return c.json(
{
success: false,
@@ -1,14 +1,14 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { getProgramMemberRole, isOrgAdminByProgramId } from '@cio/db/queries/program';
import { getCohortMemberRole, isOrgAdminByCohortId } from '@cio/db/queries/cohort';
import { ROLE } from '@cio/utils/constants';
/**
* Middleware to check if the authenticated user is a program teacher or org admin.
* Middleware to check if the authenticated user is a cohort teacher or org admin.
* Mirrors the course-team-member pattern for program routes.
*/
export const programTeamMemberMiddleware = async (c: Context, next: Next) => {
export const cohortTeamMemberMiddleware = async (c: Context, next: Next) => {
try {
const user = c.get('user');
if (!user) {
@@ -22,21 +22,21 @@ export const programTeamMemberMiddleware = async (c: Context, next: Next) => {
);
}
const programId = c.req.param('programId');
if (!programId) {
const cohortId = c.req.param('cohortId');
if (!cohortId) {
return c.json(
{
success: false,
error: 'Program ID is required',
code: 'PROGRAM_ID_REQUIRED'
code: 'COHORT_ID_REQUIRED'
},
400
);
}
const [roleId, isOrgAdmin] = await Promise.all([
getProgramMemberRole(programId, user.id),
isOrgAdminByProgramId(programId, user.id)
getCohortMemberRole(cohortId, user.id),
isOrgAdminByCohortId(cohortId, user.id)
]);
if (roleId === ROLE.TUTOR || roleId === ROLE.ADMIN || isOrgAdmin) {
@@ -52,12 +52,12 @@ export const programTeamMemberMiddleware = async (c: Context, next: Next) => {
403
);
} catch (error) {
console.error('Error in programTeamMemberMiddleware:', error);
console.error('Error in cohortTeamMemberMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify program team membership',
code: 'PROGRAM_TEAM_MEMBER_CHECK_FAILED'
code: 'COHORT_TEAM_MEMBER_CHECK_FAILED'
},
500
);
+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');
}
}
);
@@ -1,44 +1,44 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
type TCreateProgram,
type TUpdateProgram,
type TAddCourseToProgram,
type TAddProgramMembers,
type TUpdateProgramMember,
type TCreateProgramNewsfeed,
type TUpdateProgramNewsfeed,
type TUpdateProgramReaction,
type TCreateProgramNewsfeedComment
} from '@cio/utils/validation/program';
type TCreateCohort,
type TUpdateCohort,
type TAddCourseToCohort,
type TAddCohortMembers,
type TUpdateCohortMember,
type TCreateCohortNewsfeed,
type TUpdateCohortNewsfeed,
type TUpdateCohortReaction,
type TCreateCohortNewsfeedComment
} from '@cio/utils/validation/cohort';
import {
addCourseToProgram,
addProgramMember,
createProgramWithCreatorMembership,
createProgramNewsfeed as createProgramNewsfeedQuery,
createProgramNewsfeedComment as createProgramNewsfeedCommentQuery,
deleteProgram as deleteProgramQuery,
deleteProgramNewsfeed as deleteProgramNewsfeedQuery,
deleteProgramNewsfeedComment as deleteProgramNewsfeedCommentQuery,
getEnrolledProgramsByProfile,
getProgramById,
getProgramMemberByProfileId,
getProgramMembers,
getProgramNewsfeed,
getProgramNewsfeedById,
getProgramNewsfeedCommentById,
getProgramNewsfeedComments,
getProgramsByOrg,
getCoursesByProgram,
getProgramMemberRole,
isProgramCourse,
isProgramMember,
removeCourseFromProgram,
removeProgramMember,
updateProgram as updateProgramQuery,
updateProgramMember as updateProgramMemberQuery,
updateProgramNewsfeed as updateProgramNewsfeedQuery,
updateProgramNewsfeedReaction
} from '@cio/db/queries/program';
addCourseToCohort,
addCohortMember,
createCohortWithCreatorMembership,
createCohortNewsfeed as createCohortNewsfeedQuery,
createCohortNewsfeedComment as createCohortNewsfeedCommentQuery,
deleteCohort as deleteCohortQuery,
deleteCohortNewsfeed as deleteCohortNewsfeedQuery,
deleteCohortNewsfeedComment as deleteCohortNewsfeedCommentQuery,
getEnrolledCohortsByProfile,
getCohortById,
getCohortMemberByProfileId,
getCohortMembers,
getCohortNewsfeed,
getCohortNewsfeedById,
getCohortNewsfeedCommentById,
getCohortNewsfeedComments,
getCohortsByOrg,
getCoursesByCohort,
getCohortMemberRole,
isCohortCourse,
isCohortMember,
removeCourseFromCohort,
removeCohortMember,
updateCohort as updateCohortQuery,
updateCohortMember as updateCohortMemberQuery,
updateCohortNewsfeed as updateCohortNewsfeedQuery,
updateCohortNewsfeedReaction
} from '@cio/db/queries/cohort';
import { getCourseGroupIds } from '@cio/db/queries/course';
import { insertGroupMembersOnConflictDoNothing } from '@cio/db/queries/group';
import { getProfileByEmail } from '@cio/db/queries/auth';
@@ -46,16 +46,16 @@ import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/or
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
type ProgramMemberEnrollment = {
type CohortMemberEnrollment = {
profileId: string;
email: string | null;
roleId: number;
};
async function enrollProgramStudentsInGroups(
async function enrollCohortStudentsInGroups(
organizationId: string,
groupIds: string[],
members: ProgramMemberEnrollment[]
members: CohortMemberEnrollment[]
) {
const studentMembers = members.filter((member) => member.profileId && member.roleId === ROLE.STUDENT);
const uniqueGroupIds = [...new Set(groupIds)];
@@ -91,11 +91,11 @@ async function enrollProgramStudentsInGroups(
// ─── Program CRUD ────────────────────────────────────────────────────────────
export async function createProgram(organizationId: string, profileId: string, data: TCreateProgram) {
export async function createCohort(organizationId: string, profileId: string, data: TCreateCohort) {
try {
const description = data.description?.trim() || `A program for ${data.name}`;
return createProgramWithCreatorMembership(
return createCohortWithCreatorMembership(
{
organizationId,
createdByProfileId: profileId,
@@ -108,72 +108,72 @@ export async function createProgram(organizationId: string, profileId: string, d
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to create program',
ErrorCodes.PROGRAM_CREATE_FAILED,
error instanceof Error ? error.message : 'Failed to create cohort',
ErrorCodes.COHORT_CREATE_FAILED,
500
);
}
}
export async function getProgram(programId: string) {
export async function getCohort(cohortId: string) {
try {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
const cohort = await getCohortById(cohortId);
if (!cohort) {
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
}
return program;
return cohort;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to get program',
ErrorCodes.PROGRAM_NOT_FOUND,
error instanceof Error ? error.message : 'Failed to get cohort',
ErrorCodes.COHORT_NOT_FOUND,
500
);
}
}
export async function listOrgPrograms(organizationId: string) {
export async function listOrgCohorts(organizationId: string) {
try {
return getProgramsByOrg(organizationId);
return getCohortsByOrg(organizationId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list programs',
error instanceof Error ? error.message : 'Failed to list cohorts',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function updateProgram(programId: string, data: TUpdateProgram) {
export async function updateCohort(cohortId: string, data: TUpdateCohort) {
try {
const updated = await updateProgramQuery(programId, data);
const updated = await updateCohortQuery(cohortId, data);
if (!updated) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
}
return updated;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update program',
ErrorCodes.PROGRAM_UPDATE_FAILED,
error instanceof Error ? error.message : 'Failed to update cohort',
ErrorCodes.COHORT_UPDATE_FAILED,
500
);
}
}
export async function deleteProgram(programId: string) {
export async function deleteCohort(cohortId: string) {
try {
const deleted = await deleteProgramQuery(programId);
const deleted = await deleteCohortQuery(cohortId);
if (!deleted) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
}
return deleted;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete program',
ErrorCodes.PROGRAM_DELETE_FAILED,
error instanceof Error ? error.message : 'Failed to delete cohort',
ErrorCodes.COHORT_DELETE_FAILED,
500
);
}
@@ -181,28 +181,28 @@ export async function deleteProgram(programId: string) {
// ─── Program Members ─────────────────────────────────────────────────────────
export async function listProgramMembers(programId: string) {
export async function listCohortMembers(cohortId: string) {
try {
return getProgramMembers(programId);
return getCohortMembers(cohortId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list program members',
error instanceof Error ? error.message : 'Failed to list cohort members',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function addProgramMembers(programId: string, data: TAddProgramMembers) {
export async function addCohortMembers(cohortId: string, data: TAddCohortMembers) {
try {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
const cohort = await getCohortById(cohortId);
if (!cohort) {
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
}
const programCourses = await getCoursesByProgram(programId);
const courseGroupIds = (await getCourseGroupIds(programCourses.map((course) => course.course.id))).map(
const cohortCourses = await getCoursesByCohort(cohortId);
const courseGroupIds = (await getCourseGroupIds(cohortCourses.map((course) => course.course.id))).map(
(courseGroup) => courseGroup.groupId
);
@@ -213,13 +213,9 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb
const normalizedEmail = email?.toLowerCase().trim() ?? profile?.email ?? null;
if (profileId) {
const existing = await getProgramMemberByProfileId(programId, profileId);
const existing = await getCohortMemberByProfileId(cohortId, profileId);
if (existing) {
throw new AppError(
`${email} is already a member of this program`,
ErrorCodes.MEMBER_ALREADY_IN_PROGRAM,
409
);
throw new AppError(`${email} is already a member of this cohort`, ErrorCodes.MEMBER_ALREADY_IN_COHORT, 409);
}
}
@@ -232,9 +228,9 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb
}
return db.transaction(async (tx) => {
const member = await addProgramMember(
const member = await addCohortMember(
{
programId,
cohortId,
profileId,
roleId,
email: normalizedEmail ?? undefined
@@ -248,7 +244,7 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb
await insertOrganizationMembersOnConflictDoNothing(
[
{
organizationId: program.organizationId,
organizationId: cohort.organizationId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: normalizedEmail ?? undefined,
@@ -285,18 +281,18 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to add program members',
error instanceof Error ? error.message : 'Failed to add cohort members',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function removeProgramMemberService(_programId: string, memberId: string) {
export async function removeCohortMemberService(_cohortId: string, memberId: string) {
try {
const deleted = await removeProgramMember(memberId);
const deleted = await removeCohortMember(memberId);
if (!deleted) {
throw new AppError('Program member not found', ErrorCodes.PROGRAM_MEMBER_NOT_FOUND, 404);
throw new AppError('Cohort member not found', ErrorCodes.COHORT_MEMBER_NOT_FOUND, 404);
}
return deleted;
} catch (error) {
@@ -309,30 +305,30 @@ export async function removeProgramMemberService(_programId: string, memberId: s
}
}
export async function updateProgramMemberService(_programId: string, memberId: string, data: TUpdateProgramMember) {
export async function updateCohortMemberService(_cohortId: string, memberId: string, data: TUpdateCohortMember) {
try {
const updated = await updateProgramMemberQuery(memberId, { roleId: data.roleId });
const updated = await updateCohortMemberQuery(memberId, { roleId: data.roleId });
if (!updated) {
throw new AppError('Program member not found', ErrorCodes.PROGRAM_MEMBER_NOT_FOUND, 404);
throw new AppError('Cohort member not found', ErrorCodes.COHORT_MEMBER_NOT_FOUND, 404);
}
return updated;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update program member',
error instanceof Error ? error.message : 'Failed to update cohort member',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function getEnrolledPrograms(profileId: string) {
export async function getEnrolledCohorts(profileId: string) {
try {
return getEnrolledProgramsByProfile(profileId);
return getEnrolledCohortsByProfile(profileId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to get enrolled programs',
error instanceof Error ? error.message : 'Failed to get enrolled cohorts',
ErrorCodes.INTERNAL_ERROR,
500
);
@@ -341,36 +337,36 @@ export async function getEnrolledPrograms(profileId: string) {
// ─── Program Courses ─────────────────────────────────────────────────────────
export async function listProgramCourses(programId: string, profileId: string) {
export async function listCohortCourses(cohortId: string, profileId: string) {
try {
const roleId = await getProgramMemberRole(programId, profileId);
const roleId = await getCohortMemberRole(cohortId, profileId);
const onlyPublished = roleId === ROLE.STUDENT;
return getCoursesByProgram(programId, onlyPublished);
return getCoursesByCohort(cohortId, onlyPublished);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list program courses',
error instanceof Error ? error.message : 'Failed to list cohort courses',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function addCourseToProgramService(programId: string, data: TAddCourseToProgram) {
export async function addCourseToCohortService(cohortId: string, data: TAddCourseToCohort) {
try {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
const cohort = await getCohortById(cohortId);
if (!cohort) {
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
}
const alreadyAdded = await isProgramCourse(programId, data.courseId);
const alreadyAdded = await isCohortCourse(cohortId, data.courseId);
if (alreadyAdded) {
throw new AppError('Course is already in this program', ErrorCodes.COURSE_ALREADY_IN_PROGRAM, 409);
throw new AppError('Course is already in this cohort', ErrorCodes.COURSE_ALREADY_IN_COHORT, 409);
}
const result = await addCourseToProgram(programId, data.courseId);
const students = (await getProgramMembers(programId))
const result = await addCourseToCohort(cohortId, data.courseId);
const students = (await getCohortMembers(cohortId))
.filter((member) => member.roleId === ROLE.STUDENT && member.profileId)
.map((member) => ({
profileId: member.profileId!,
@@ -383,7 +379,7 @@ export async function addCourseToProgramService(programId: string, data: TAddCou
.filter((groupId): groupId is string => Boolean(groupId));
if (students.length > 0 && courseGroupIds.length > 0) {
await enrollProgramStudentsInGroups(program.organizationId, courseGroupIds, students);
await enrollCohortStudentsInGroups(cohort.organizationId, courseGroupIds, students);
}
return result;
@@ -397,9 +393,9 @@ export async function addCourseToProgramService(programId: string, data: TAddCou
}
}
export async function removeCourseFromProgramService(programId: string, courseId: string) {
export async function removeCourseFromCohortService(cohortId: string, courseId: string) {
try {
const deleted = await removeCourseFromProgram(programId, courseId);
const deleted = await removeCourseFromCohort(cohortId, courseId);
if (!deleted) {
throw new AppError('Course not found in program', ErrorCodes.NOT_FOUND, 404);
}
@@ -416,28 +412,28 @@ export async function removeCourseFromProgramService(programId: string, courseId
// ─── Program Newsfeed ────────────────────────────────────────────────────────
export async function listProgramNewsfeed(programId: string, options: { cursor?: string; limit: number }) {
export async function listCohortNewsfeed(cohortId: string, options: { cursor?: string; limit: number }) {
try {
return getProgramNewsfeed(programId, options);
return getCohortNewsfeed(cohortId, options);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list program newsfeed',
error instanceof Error ? error.message : 'Failed to list cohort newsfeed',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function createProgramNewsfeedService(programId: string, profileId: string, data: TCreateProgramNewsfeed) {
export async function createCohortNewsfeedService(cohortId: string, profileId: string, data: TCreateCohortNewsfeed) {
try {
const member = await getProgramMemberByProfileId(programId, profileId);
const member = await getCohortMemberByProfileId(cohortId, profileId);
if (!member) {
throw new AppError('User is not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
throw new AppError('User is not a member of this cohort', ErrorCodes.COHORT_FORBIDDEN, 403);
}
return createProgramNewsfeedQuery({
programId,
return createCohortNewsfeedQuery({
cohortId,
authorId: member.id,
content: data.content,
isPinned: data.isPinned ?? false
@@ -445,58 +441,58 @@ export async function createProgramNewsfeedService(programId: string, profileId:
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to create program newsfeed',
error instanceof Error ? error.message : 'Failed to create cohort newsfeed',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function updateProgramNewsfeedService(feedId: string, data: TUpdateProgramNewsfeed) {
export async function updateCohortNewsfeedService(feedId: string, data: TUpdateCohortNewsfeed) {
try {
const feed = await getProgramNewsfeedById(feedId);
const feed = await getCohortNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404);
}
return updateProgramNewsfeedQuery(feedId, data);
return updateCohortNewsfeedQuery(feedId, data);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update program newsfeed',
error instanceof Error ? error.message : 'Failed to update cohort newsfeed',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function updateProgramNewsfeedReactionService(feedId: string, data: TUpdateProgramReaction) {
export async function updateCohortNewsfeedReactionService(feedId: string, data: TUpdateCohortReaction) {
try {
const feed = await getProgramNewsfeedById(feedId);
const feed = await getCohortNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404);
}
return updateProgramNewsfeedReaction(feedId, data.reaction);
return updateCohortNewsfeedReaction(feedId, data.reaction);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update program newsfeed reaction',
error instanceof Error ? error.message : 'Failed to update cohort newsfeed reaction',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function deleteProgramNewsfeedService(feedId: string) {
export async function deleteCohortNewsfeedService(feedId: string) {
try {
const feed = await getProgramNewsfeedById(feedId);
const feed = await getCohortNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404);
}
return deleteProgramNewsfeedQuery(feedId);
return deleteCohortNewsfeedQuery(feedId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete program newsfeed',
error instanceof Error ? error.message : 'Failed to delete cohort newsfeed',
ErrorCodes.INTERNAL_ERROR,
500
);
@@ -505,67 +501,67 @@ export async function deleteProgramNewsfeedService(feedId: string) {
// ─── Program Newsfeed Comments ────────────────────────────────────────────────
export async function listProgramNewsfeedComments(feedId: string) {
export async function listCohortNewsfeedComments(feedId: string) {
try {
const feed = await getProgramNewsfeedById(feedId);
const feed = await getCohortNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404);
}
return getProgramNewsfeedComments(feedId);
return getCohortNewsfeedComments(feedId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list program newsfeed comments',
error instanceof Error ? error.message : 'Failed to list cohort newsfeed comments',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function createProgramNewsfeedCommentService(
export async function createCohortNewsfeedCommentService(
feedId: string,
profileId: string,
data: TCreateProgramNewsfeedComment
data: TCreateCohortNewsfeedComment
) {
try {
const feed = await getProgramNewsfeedById(feedId);
const feed = await getCohortNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404);
}
const member = await getProgramMemberByProfileId(feed.programId!, profileId);
const member = await getCohortMemberByProfileId(feed.cohortId!, profileId);
if (!member) {
throw new AppError('User is not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
throw new AppError('User is not a member of this cohort', ErrorCodes.COHORT_FORBIDDEN, 403);
}
return createProgramNewsfeedCommentQuery({
programNewsfeedId: feedId,
return createCohortNewsfeedCommentQuery({
cohortNewsfeedId: feedId,
authorId: member.id,
content: data.content
});
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to create program newsfeed comment',
error instanceof Error ? error.message : 'Failed to create cohort newsfeed comment',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function deleteProgramNewsfeedCommentService(commentId: number) {
export async function deleteCohortNewsfeedCommentService(commentId: number) {
try {
const comment = await getProgramNewsfeedCommentById(commentId);
const comment = await getCohortNewsfeedCommentById(commentId);
if (!comment) {
throw new AppError('Comment not found', ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND, 404);
throw new AppError('Comment not found', ErrorCodes.COHORT_NEWSFEED_COMMENT_NOT_FOUND, 404);
}
const feed = await getProgramNewsfeedById(comment.programNewsfeedId!);
const feed = await getCohortNewsfeedById(comment.cohortNewsfeedId!);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
throw new AppError('Cohort newsfeed item not found', ErrorCodes.COHORT_NEWSFEED_NOT_FOUND, 404);
}
return deleteProgramNewsfeedCommentQuery(commentId);
return deleteCohortNewsfeedCommentQuery(commentId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete program newsfeed comment',
error instanceof Error ? error.message : 'Failed to delete cohort newsfeed comment',
ErrorCodes.INTERNAL_ERROR,
500
);
@@ -574,9 +570,9 @@ export async function deleteProgramNewsfeedCommentService(commentId: number) {
// ─── Auth Helpers ────────────────────────────────────────────────────────────
async function assertProgramMember(programId: string, profileId: string) {
const member = await isProgramMember(programId, profileId);
async function assertCohortMember(cohortId: string, profileId: string) {
const member = await isCohortMember(cohortId, profileId);
if (!member) {
throw new AppError('You are not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
throw new AppError('You are not a member of this cohort', ErrorCodes.COHORT_FORBIDDEN, 403);
}
}
@@ -4,49 +4,45 @@ import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { buildEmailBranding } from '@cio/email';
import {
countAssignmentsByStatus,
createProgramGoal,
deleteProgramGoal,
createCohortGoal,
deleteCohortGoal,
getAssignmentsForProfile,
getLatestCompletionRecordsForProfilesAndCourses,
getMaxScoresForProfilesAndCourses,
getNonComplianceCourseCompletions,
getProgramById,
getProgramGoalAssignments,
getProgramGoalById,
getProgramGoals,
getProgramGoalsByOrg,
getProgramMembers,
listAllActiveProgramGoals,
getCohortById,
getCohortGoalAssignments,
getCohortGoalById,
getCohortGoals,
getCohortGoalsByOrg,
getCohortMembers,
listAllActiveCohortGoals,
listAssignmentsForReminderScan,
type TNewProgramGoalAssignment,
type TProgramGoal,
type TProgramGoalAssignment,
updateProgramGoal as updateProgramGoalQuery,
upsertProgramGoalAssignments
} from '@cio/db/queries/program';
type TNewCohortGoalAssignment,
type TCohortGoal,
type TCohortGoalAssignment,
updateCohortGoal as updateCohortGoalQuery,
upsertCohortGoalAssignments
} from '@cio/db/queries/cohort';
import { ROLE } from '@cio/utils/constants';
import type { TCreateProgramGoal, TUpdateProgramGoal } from '@cio/utils/validation/program';
import type { TCreateCohortGoal, TUpdateCohortGoal } from '@cio/utils/validation/cohort';
const AT_RISK_DAYS = 3;
const DAY_MS = 24 * 60 * 60 * 1000;
type GoalType = TProgramGoal['type'];
type GoalType = TCohortGoal['type'];
// ─── CRUD ────────────────────────────────────────────────────────────────────
export async function createGoal(
programId: string,
profileId: string,
data: TCreateProgramGoal
): Promise<TProgramGoal> {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
export async function createGoal(cohortId: string, profileId: string, data: TCreateCohortGoal): Promise<TCohortGoal> {
const cohort = await getCohortById(cohortId);
if (!cohort) {
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
}
try {
const goal = await createProgramGoal({
programId,
const goal = await createCohortGoal({
cohortId,
title: data.title,
description: data.description ?? null,
type: data.type,
@@ -71,16 +67,16 @@ export async function createGoal(
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to create goal',
ErrorCodes.PROGRAM_GOAL_CREATE_FAILED,
ErrorCodes.COHORT_GOAL_CREATE_FAILED,
500
);
}
}
export async function listGoals(
programId: string
): Promise<Array<TProgramGoal & { statusCounts: Record<string, number> }>> {
const goals = await getProgramGoals(programId);
cohortId: string
): Promise<Array<TCohortGoal & { statusCounts: Record<string, number> }>> {
const goals = await getCohortGoals(cohortId);
if (goals.length === 0) return [];
const counts = await countAssignmentsByStatus(goals.map((goal) => goal.id));
@@ -91,23 +87,23 @@ export async function listGoals(
}));
}
export async function getGoal(goalId: string): Promise<TProgramGoal> {
const goal = await getProgramGoalById(goalId);
export async function getGoal(goalId: string): Promise<TCohortGoal> {
const goal = await getCohortGoalById(goalId);
if (!goal) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
throw new AppError('Goal not found', ErrorCodes.COHORT_GOAL_NOT_FOUND, 404);
}
return goal;
}
export async function updateGoal(goalId: string, data: TUpdateProgramGoal): Promise<TProgramGoal> {
const existing = await getProgramGoalById(goalId);
export async function updateGoal(goalId: string, data: TUpdateCohortGoal): Promise<TCohortGoal> {
const existing = await getCohortGoalById(goalId);
if (!existing) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
throw new AppError('Goal not found', ErrorCodes.COHORT_GOAL_NOT_FOUND, 404);
}
try {
const updated = await updateProgramGoalQuery(goalId, {
const updated = await updateCohortGoalQuery(goalId, {
title: data.title,
description: data.description ?? undefined,
type: data.type,
@@ -123,7 +119,7 @@ export async function updateGoal(goalId: string, data: TUpdateProgramGoal): Prom
status: data.status
});
if (!updated) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
throw new AppError('Goal not found', ErrorCodes.COHORT_GOAL_NOT_FOUND, 404);
}
await evaluateGoal(goalId).catch((error) => {
@@ -135,20 +131,20 @@ export async function updateGoal(goalId: string, data: TUpdateProgramGoal): Prom
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update goal',
ErrorCodes.PROGRAM_GOAL_UPDATE_FAILED,
ErrorCodes.COHORT_GOAL_UPDATE_FAILED,
500
);
}
}
export async function archiveGoal(goalId: string): Promise<TProgramGoal> {
export async function archiveGoal(goalId: string): Promise<TCohortGoal> {
return updateGoal(goalId, { status: 'archived' });
}
export async function removeGoal(goalId: string): Promise<TProgramGoal> {
const deleted = await deleteProgramGoal(goalId);
export async function removeGoal(goalId: string): Promise<TCohortGoal> {
const deleted = await deleteCohortGoal(goalId);
if (!deleted) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
throw new AppError('Goal not found', ErrorCodes.COHORT_GOAL_NOT_FOUND, 404);
}
return deleted;
@@ -164,7 +160,7 @@ export async function removeGoal(goalId: string): Promise<TProgramGoal> {
* - relative_to_join → joinedAt + relativeDays
* - recurring → goal.deadlineDate (current cycle); cycle advance handled elsewhere
*/
function computeDueDate(goal: TProgramGoal, joinedAt: Date): Date | null {
function computeDueDate(goal: TCohortGoal, joinedAt: Date): Date | null {
if (goal.deadlineKind === 'none') return null;
if (goal.deadlineKind === 'absolute') {
return goal.deadlineDate ? new Date(goal.deadlineDate) : null;
@@ -193,8 +189,8 @@ function decideStatus(args: {
required: number;
dueDate: Date | null;
now: Date;
previousStatus: TProgramGoalAssignment['status'] | null;
}): { status: TProgramGoalAssignment['status']; completedAt: Date | null } {
previousStatus: TCohortGoalAssignment['status'] | null;
}): { status: TCohortGoalAssignment['status']; completedAt: Date | null } {
const { completed, required, dueDate, now, previousStatus } = args;
if (previousStatus === 'waived') {
@@ -223,15 +219,15 @@ function decideStatus(args: {
/**
* Re-evaluate every assignment for one goal.
*
* Pulls all student program-members, computes a per-learner status, and upserts
* Pulls all student cohort-members, computes a per-learner status, and upserts
* `program_goal_assignment`. Designed to be safe to run repeatedly (idempotent)
* and cheap enough for daily cron over hundreds of programs.
*/
export async function evaluateGoal(goalId: string): Promise<{ evaluated: number }> {
const goal = await getProgramGoalById(goalId);
const goal = await getCohortGoalById(goalId);
if (!goal) return { evaluated: 0 };
const members = await getProgramMembers(goal.programId);
const members = await getCohortMembers(goal.cohortId);
const studentMembers = members.filter((member) => member.roleId === ROLE.STUDENT && member.profileId);
if (studentMembers.length === 0) return { evaluated: 0 };
@@ -244,7 +240,7 @@ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number
goal.type === 'score' || goal.type === 'pass_rate'
? getMaxScoresForProfilesAndCourses(profileIds, courseIds)
: Promise.resolve(new Map<string, number>()),
getProgramGoalAssignments(goalId)
getCohortGoalAssignments(goalId)
]);
const completionByKey = new Map<string, { status: string; score: number | null }>();
@@ -255,13 +251,13 @@ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number
});
}
const previousByMember = new Map(existingAssignments.map((assignment) => [assignment.programMemberId, assignment]));
const previousByMember = new Map(existingAssignments.map((assignment) => [assignment.cohortMemberId, assignment]));
const now = new Date();
const requiredFor = (type: GoalType, total: number) =>
type === 'n_of_m' && goal.requiredCount ? Math.min(goal.requiredCount, total) : total;
const rows: TNewProgramGoalAssignment[] = [];
const rows: TNewCohortGoalAssignment[] = [];
for (const member of studentMembers) {
if (!member.profileId) continue;
@@ -303,7 +299,7 @@ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number
rows.push({
goalId,
programMemberId: member.id,
cohortMemberId: member.id,
dueDate: dueDate ? dueDate.toISOString() : null,
status: decision.status,
completedCount: completed,
@@ -314,17 +310,17 @@ export async function evaluateGoal(goalId: string): Promise<{ evaluated: number
});
}
await upsertProgramGoalAssignments(rows);
await upsertCohortGoalAssignments(rows);
return { evaluated: rows.length };
}
/**
* Re-evaluate every active goal in a program. Used after material program
* Re-evaluate every active goal in a cohort. Used after material program
* changes (member added, course added) or by cron sweeps.
*/
export async function evaluateProgramGoals(programId: string): Promise<{ evaluated: number }> {
const goals = await getProgramGoals(programId);
export async function evaluateCohortGoals(cohortId: string): Promise<{ evaluated: number }> {
const goals = await getCohortGoals(cohortId);
let evaluated = 0;
for (const goal of goals) {
const result = await evaluateGoal(goal.id);
@@ -338,7 +334,7 @@ export async function evaluateProgramGoals(programId: string): Promise<{ evaluat
* Re-evaluate all goals in an organization. Used by the daily cron.
*/
export async function evaluateOrgGoals(organizationId: string): Promise<{ evaluated: number; goals: number }> {
const goals = await getProgramGoalsByOrg(organizationId);
const goals = await getCohortGoalsByOrg(organizationId);
let evaluated = 0;
for (const goal of goals) {
const result = await evaluateGoal(goal.id);
@@ -352,11 +348,11 @@ export async function evaluateOrgGoals(organizationId: string): Promise<{ evalua
* Re-evaluate every active goal across every org. Designed for the daily cron
* sweep — same shape as `runComplianceExpiryCheck` for the compliance feature.
*/
export async function runProgramGoalEvaluationSweep(): Promise<{
export async function runCohortGoalEvaluationSweep(): Promise<{
goalsEvaluated: number;
assignmentsEvaluated: number;
}> {
const goals = await listAllActiveProgramGoals();
const goals = await listAllActiveCohortGoals();
let assignmentsEvaluated = 0;
for (const goal of goals) {
@@ -364,7 +360,7 @@ export async function runProgramGoalEvaluationSweep(): Promise<{
const result = await evaluateGoal(goal.id);
assignmentsEvaluated += result.evaluated;
} catch (error) {
console.error(`runProgramGoalEvaluationSweep: failed to evaluate goal ${goal.id}`, error);
console.error(`runCohortGoalEvaluationSweep: failed to evaluate goal ${goal.id}`, error);
}
}
@@ -386,7 +382,7 @@ function getWholeDaysUntil(now: Date, target: Date): number {
* Idempotency: the email worker idempotency key is keyed on assignment id +
* the day bucket so a second run on the same day does not re-send.
*/
export async function runProgramGoalReminderScan(): Promise<{
export async function runCohortGoalReminderScan(): Promise<{
scanned: number;
remindersEnqueued: number;
}> {
@@ -417,27 +413,27 @@ export async function runProgramGoalReminderScan(): Promise<{
const dayKey = now.toISOString().slice(0, 10);
try {
await enqueueTransactionalEmail('programGoalReminder', {
await enqueueTransactionalEmail('cohortGoalReminder', {
to: row.email,
fields: {
orgName: row.organizationName,
programName: row.programName,
cohortName: row.cohortName,
goalTitle: row.goal.title,
daysUntilDue,
completedCount: row.assignment.completedCount ?? 0,
requiredCount: row.assignment.requiredCount ?? 0,
loginUrl: `${loginUrl}/lms/programs/${row.programId}`,
loginUrl: `${loginUrl}/lms/cohorts/${row.cohortId}`,
branding: buildEmailBranding({
name: row.organizationName,
avatarUrl: row.organizationAvatarUrl,
theme: row.organizationTheme
})
},
idempotencyKey: `program-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`
idempotencyKey: `cohort-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`
});
remindersEnqueued += 1;
} catch (error) {
console.error(`runProgramGoalReminderScan: enqueue failed for assignment ${row.assignment.id}`, error);
console.error(`runCohortGoalReminderScan: enqueue failed for assignment ${row.assignment.id}`, error);
}
}
@@ -453,11 +449,11 @@ export async function getMyGoals(profileId: string) {
// ─── Org-wide overview ───────────────────────────────────────────────────────
/**
* Cross-program goal roll-up for the org owner. Lists every active goal in the
* Cross-cohort goal roll-up for the org owner. Lists every active goal in the
* org with its per-status counts so the dashboard can group by program.
*/
export async function getOrgGoalsOverview(organizationId: string) {
const goals = await getProgramGoalsByOrg(organizationId);
const goals = await getCohortGoalsByOrg(organizationId);
if (goals.length === 0) {
return { goals: [] };
}
@@ -476,8 +472,8 @@ export async function getOrgGoalsOverview(organizationId: string) {
return {
goalId: goal.id,
programId: goal.programId,
programName: goal.programName,
cohortId: goal.cohortId,
cohortName: goal.cohortName,
title: goal.title,
type: goal.type,
deadlineKind: goal.deadlineKind,
+52
View File
@@ -0,0 +1,52 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCohortById } from '@cio/db/queries/cohort';
import type { TInviteStudentsToCohort, TAssignExistingStudentsToCohort } from '@cio/utils/validation/cohort';
import { assignAudienceToCourses, importAudienceMembers } from '@api/services/organization/audience';
/**
* Invite students to a single program by email CSV.
*
* Thin wrapper that hardcodes `cohortIds = [cohortId]` and delegates to
* `importAudienceMembers`. Lets a Cohort ADMIN/TUTOR (or org admin) issue org
* invites scoped to *their own* program, without granting org-wide invite rights.
*/
export async function inviteStudentsToCohort(
cohortId: string,
data: TInviteStudentsToCohort,
invitedByProfileId: string
) {
const cohort = await getCohortById(cohortId);
if (!cohort) {
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
}
return importAudienceMembers(
cohort.organizationId,
{
recipientCsv: data.recipientCsv,
cohortIds: [cohortId],
sendEmail: data.sendEmail,
allCourses: false,
allCohorts: false
},
invitedByProfileId
);
}
/**
* Assign existing student profiles (already members of the org audience) to a
* single program. Same scoping logic as `inviteStudentsToCohort` — restricted
* to one program at a time so the caller's program-admin permission is enough.
*/
export async function assignExistingStudentsToCohort(cohortId: string, data: TAssignExistingStudentsToCohort) {
const cohort = await getCohortById(cohortId);
if (!cohort) {
throw new AppError('Cohort not found', ErrorCodes.COHORT_NOT_FOUND, 404);
}
return assignAudienceToCourses(cohort.organizationId, {
profileIds: data.profileIds,
cohortIds: [cohortId],
sendEmail: data.sendEmail
});
}
+69 -69
View File
@@ -7,7 +7,7 @@ import type {
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
import { addCohortMember, getExistingCohortMembers, getCohortsByOrg } from '@cio/db/queries/cohort';
import {
createOrganizationInvite,
createOrganizationInviteAudits,
@@ -132,21 +132,21 @@ async function resolveCourseIdsAndNamesForImport(orgId: string, data: TImportAud
return { courseIds, courseNames };
}
async function resolveProgramIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) {
let programIds: string[] = [];
let programNames: string[] = [];
async function resolveCohortIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) {
let cohortIds: string[] = [];
let cohortNames: string[] = [];
if (data.allPrograms) {
const programs = await getProgramsByOrg(orgId);
programIds = programs.map((program) => program.id);
programNames = programs.map((program) => program.name).filter(Boolean);
} else if (data.programIds && data.programIds.length > 0) {
const programs = await getProgramsByOrg(orgId, data.programIds);
programIds = programs.map((program) => program.id);
programNames = programs.map((program) => program.name).filter(Boolean);
if (data.allCohorts) {
const cohorts = await getCohortsByOrg(orgId);
cohortIds = cohorts.map((cohort) => cohort.id);
cohortNames = cohorts.map((cohort) => cohort.name).filter(Boolean);
} else if (data.cohortIds && data.cohortIds.length > 0) {
const cohorts = await getCohortsByOrg(orgId, data.cohortIds);
cohortIds = cohorts.map((cohort) => cohort.id);
cohortNames = cohorts.map((cohort) => cohort.name).filter(Boolean);
}
return { programIds, programNames };
return { cohortIds, cohortNames };
}
async function enrollAudienceStudentProfilesInCourses(
@@ -241,14 +241,14 @@ async function enrollAudienceStudentProfilesInCourses(
};
}
async function enrollAudienceStudentProfilesInPrograms(
async function enrollAudienceStudentProfilesInCohorts(
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>,
orgId: string,
profileIds: string[],
programIds: string[],
cohortIds: string[],
shouldSendEmail: boolean
): Promise<{ assigned: number; alreadyEnrolled: number; emailsSent: number }> {
if (programIds.length === 0 || profileIds.length === 0) {
if (cohortIds.length === 0 || profileIds.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
@@ -260,25 +260,25 @@ async function enrollAudienceStudentProfilesInPrograms(
studentMembers.filter((member) => member.profileId).map((member) => [member.profileId!, member.email ?? ''])
);
const programs = await getProgramsByOrg(orgId, programIds);
if (programs.length === 0) {
const cohorts = await getCohortsByOrg(orgId, cohortIds);
if (cohorts.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
const programNameById = new Map(programs.map((program) => [program.id, program.name || 'Program']));
const cohortNameById = new Map(cohorts.map((cohort) => [cohort.id, cohort.name || 'Cohort']));
const loginUrl = getDashboardBaseUrl(organization);
const validProgramIds = programs.map((program) => program.id);
const validCohortIds = cohorts.map((cohort) => cohort.id);
const validProfiles = uniqueProfileIds.filter((profileId) => validProfileIds.has(profileId));
const pairs = validProfiles.flatMap((profileId) => validProgramIds.map((programId) => ({ programId, profileId })));
const existingSet = await getExistingProgramMembers(pairs);
const toInsert = pairs.filter((pair) => !existingSet.has(`${pair.programId}:${pair.profileId}`));
const pairs = validProfiles.flatMap((profileId) => validCohortIds.map((cohortId) => ({ cohortId, profileId })));
const existingSet = await getExistingCohortMembers(pairs);
const toInsert = pairs.filter((pair) => !existingSet.has(`${pair.cohortId}:${pair.profileId}`));
const alreadyEnrolled = pairs.length - toInsert.length;
if (toInsert.length > 0) {
await Promise.all(
toInsert.map((pair) =>
addProgramMember({
programId: pair.programId,
addCohortMember({
cohortId: pair.cohortId,
roleId: ROLE.STUDENT,
profileId: pair.profileId,
email: profileEmailMap.get(pair.profileId) || undefined
@@ -297,20 +297,20 @@ async function enrollAudienceStudentProfilesInPrograms(
const email = profileEmailMap.get(pair.profileId)!;
try {
await enqueueTransactionalEmail('studentProgramWelcome', {
await enqueueTransactionalEmail('studentCohortWelcome', {
to: email,
fields: {
orgName: organization.name,
programName: programNameById.get(pair.programId) || 'Program',
cohortName: cohortNameById.get(pair.cohortId) || 'Cohort',
loginUrl,
branding: buildEmailBranding(organization)
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}`
idempotencyKey: `audience-cohort-welcome:${pair.cohortId}:${pair.profileId}`
});
emailsSent++;
} catch (emailError) {
console.error(`enrollAudienceStudentProfilesInPrograms enqueue error for ${email}:`, emailError);
console.error(`enrollAudienceStudentProfilesInCohorts enqueue error for ${email}:`, emailError);
}
})
);
@@ -328,12 +328,12 @@ async function createStudentOrgInvitesAndSendEmails(input: {
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>;
emails: string[];
courseIds: string[];
programIds: string[];
cohortIds: string[];
accessNamesLabel: string | undefined;
invitedByProfileId: string;
shouldSendEmail: boolean;
}): Promise<{ created: number; emailsSent: number; emailsFailed: number }> {
const { orgId, organization, emails, courseIds, programIds, accessNamesLabel, invitedByProfileId, shouldSendEmail } =
const { orgId, organization, emails, courseIds, cohortIds, accessNamesLabel, invitedByProfileId, shouldSendEmail } =
input;
if (emails.length === 0) {
@@ -360,7 +360,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
metadata: {
source: 'AUDIENCE_IMPORT',
courseIds: courseIds.length > 0 ? courseIds : undefined,
programIds: programIds.length > 0 ? programIds : undefined
cohortIds: cohortIds.length > 0 ? cohortIds : undefined
}
}
};
@@ -386,7 +386,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
roleName: 'Student',
expiresAt,
courseIds,
programIds
cohortIds
}
};
})
@@ -504,8 +504,8 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
}
const { courseIds, courseNames } = await resolveCourseIdsAndNamesForImport(orgId, data);
const { programIds, programNames } = await resolveProgramIdsAndNamesForImport(orgId, data);
const accessNames = [...courseNames, ...programNames];
const { cohortIds, cohortNames } = await resolveCohortIdsAndNamesForImport(orgId, data);
const accessNames = [...courseNames, ...cohortNames];
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
const assignedToCourses = await enrollAudienceStudentProfilesInCourses(
@@ -515,11 +515,11 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
courseIds,
data.sendEmail
);
const assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
const assignedToCohorts = await enrollAudienceStudentProfilesInCohorts(
organization,
orgId,
existingStudentProfileIds,
programIds,
cohortIds,
data.sendEmail
);
@@ -554,14 +554,14 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
}
}
if (programIds.length > 0) {
if (cohortIds.length > 0) {
const profiles = await getProfilesByEmails(newEmails);
if (profiles.length > 0) {
await enrollAudienceStudentProfilesInPrograms(
await enrollAudienceStudentProfilesInCohorts(
organization,
orgId,
profiles.map((profile) => profile.id),
programIds,
cohortIds,
false
);
}
@@ -572,7 +572,7 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
organization,
emails: newEmails,
courseIds,
programIds,
cohortIds,
accessNamesLabel,
invitedByProfileId,
shouldSendEmail: data.sendEmail
@@ -590,7 +590,7 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
organization,
emails: pendingStudentEmails,
courseIds,
programIds,
cohortIds,
accessNamesLabel,
invitedByProfileId,
shouldSendEmail: data.sendEmail
@@ -601,12 +601,12 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
return {
imported,
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
assigned: assignedToCourses.assigned + assignedToCohorts.assigned,
alreadyEnrolledInCourses: assignedToCourses.alreadyEnrolled,
alreadyEnrolledInPrograms: assignedToPrograms.alreadyEnrolled,
alreadyEnrolledInCohorts: assignedToCohorts.alreadyEnrolled,
pendingInvitesRenewed: pendingStudentEmails.length,
duplicates: recipients.duplicates.length,
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent + importEmailsSent + pendingEmailsSent,
emailsSent: assignedToCourses.emailsSent + assignedToCohorts.emailsSent + importEmailsSent + pendingEmailsSent,
emailsFailed: importEmailsFailed + pendingEmailsFailed
};
}
@@ -628,9 +628,9 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
const emailToUse = member.email.toLowerCase().trim();
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, emailToUse);
const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
const meta = (latestInvite?.metadata as { courseIds?: string[]; cohortIds?: string[] } | undefined) ?? {};
const courseIdsFromMetadata = meta.courseIds?.filter(Boolean) ?? [];
const programIdsFromMetadata = meta.programIds?.filter(Boolean) ?? [];
const cohortIdsFromMetadata = meta.cohortIds?.filter(Boolean) ?? [];
let courseIds: string[] = [];
let courseNames: string[] = [];
@@ -640,19 +640,19 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
courseNames = courses.items.map((c) => c.title).filter(Boolean);
}
let programIds: string[] = [];
let programNames: string[] = [];
if (programIdsFromMetadata.length > 0) {
const programs = await getProgramsByOrg(orgId, programIdsFromMetadata);
programIds = programs.map((program) => program.id);
programNames = programs.map((program) => program.name).filter(Boolean);
let cohortIds: string[] = [];
let cohortNames: string[] = [];
if (cohortIdsFromMetadata.length > 0) {
const cohorts = await getCohortsByOrg(orgId, cohortIdsFromMetadata);
cohortIds = cohorts.map((cohort) => cohort.id);
cohortNames = cohorts.map((cohort) => cohort.name).filter(Boolean);
}
await revokeActiveOrganizationInvitesByEmails(orgId, [emailToUse], invitedByProfileId);
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
const token = generateToken();
const accessNames = [...courseNames, ...programNames];
const accessNames = [...courseNames, ...cohortNames];
const accessNamesLabel = accessNames.length > 0 ? accessNames.join(', ') : undefined;
const invite = await createOrganizationInvite({
@@ -666,7 +666,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
metadata: {
source: 'AUDIENCE_RESEND',
courseIds: courseIds.length > 0 ? courseIds : undefined,
programIds: programIds.length > 0 ? programIds : undefined
cohortIds: cohortIds.length > 0 ? cohortIds : undefined
}
});
@@ -684,7 +684,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
roleName: 'Student',
expiresAt,
courseIds,
programIds
cohortIds
}
}
]);
@@ -788,10 +788,10 @@ export async function assignAudienceToCourses(orgId: string, data: TAssignAudien
}
const courseIds = data.courseIds ?? [];
const programIds = data.programIds ?? [];
const cohortIds = data.cohortIds ?? [];
let assignedToCourses = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
let assignedToPrograms = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
let assignedToCohorts = { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
if (courseIds.length > 0) {
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
@@ -808,25 +808,25 @@ export async function assignAudienceToCourses(orgId: string, data: TAssignAudien
);
}
if (programIds.length > 0) {
const programs = await getProgramsByOrg(orgId, programIds);
if (programs.length === 0) {
throw new AppError('No valid programs found', ErrorCodes.VALIDATION_ERROR, 400, 'programIds');
if (cohortIds.length > 0) {
const cohorts = await getCohortsByOrg(orgId, cohortIds);
if (cohorts.length === 0) {
throw new AppError('No valid cohorts found', ErrorCodes.VALIDATION_ERROR, 400, 'cohortIds');
}
assignedToPrograms = await enrollAudienceStudentProfilesInPrograms(
assignedToCohorts = await enrollAudienceStudentProfilesInCohorts(
organization,
orgId,
data.profileIds,
programIds,
cohortIds,
data.sendEmail
);
}
return {
assigned: assignedToCourses.assigned + assignedToPrograms.assigned,
alreadyEnrolled: assignedToCourses.alreadyEnrolled + assignedToPrograms.alreadyEnrolled,
emailsSent: assignedToCourses.emailsSent + assignedToPrograms.emailsSent
assigned: assignedToCourses.assigned + assignedToCohorts.assigned,
alreadyEnrolled: assignedToCourses.alreadyEnrolled + assignedToCohorts.alreadyEnrolled,
emailsSent: assignedToCourses.emailsSent + assignedToCohorts.emailsSent
};
}
@@ -882,14 +882,14 @@ export async function updatePendingAudienceMemberEmail(
}
const latestInvite = await getLatestOrganizationInviteRowByOrgAndEmail(orgId, currentEmail);
const meta = (latestInvite?.metadata as { courseIds?: string[]; programIds?: string[] } | undefined) ?? {};
const meta = (latestInvite?.metadata as { courseIds?: string[]; cohortIds?: string[] } | undefined) ?? {};
await createStudentOrgInvitesAndSendEmails({
orgId,
organization,
emails: [normalizedEmail],
courseIds: meta.courseIds?.filter(Boolean) ?? [],
programIds: meta.programIds?.filter(Boolean) ?? [],
cohortIds: meta.cohortIds?.filter(Boolean) ?? [],
accessNamesLabel: undefined,
invitedByProfileId,
shouldSendEmail: data.sendEmail
+21 -21
View File
@@ -22,14 +22,14 @@ import {
} from '@cio/db/queries/organization';
import { getCourseGroupIds } from '@cio/db/queries/course';
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
import { addProgramMember, getExistingProgramMembers } from '@cio/db/queries/program';
import { addCohortMember, getExistingCohortMembers } from '@cio/db/queries/cohort';
import { ROLE } from '@cio/utils/constants';
import type { TNewOrganizationInviteAudit } from '@db/types';
import crypto from 'node:crypto';
import { db, type DbOrTxClient } from '@cio/db/drizzle';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { parseCourseIdsFromInviteMetadata, parseCohortIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { buildEmailBranding } from '@cio/email';
@@ -404,7 +404,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
if (invite) {
const metadata = invite.invite.metadata;
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
const programIds = parseProgramIdsFromInviteMetadata(metadata);
const cohortIds = parseCohortIdsFromInviteMetadata(metadata);
if (courseIds.length > 0) {
try {
@@ -422,19 +422,19 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
}
}
if (programIds.length > 0) {
if (cohortIds.length > 0) {
try {
const existingProgramMemberships = await getExistingProgramMembers(
programIds.map((programId) => ({ programId, profileId: user.id }))
const existingCohortMemberships = await getExistingCohortMembers(
cohortIds.map((cohortId) => ({ cohortId, profileId: user.id }))
);
const programIdsToInsert = programIds.filter(
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
const cohortIdsToInsert = cohortIds.filter(
(cohortId) => !existingCohortMemberships.has(`${cohortId}:${user.id}`)
);
await Promise.all(
programIdsToInsert.map((programId) =>
addProgramMember({
programId,
cohortIdsToInsert.map((cohortId) =>
addCohortMember({
cohortId,
roleId: invite.invite.roleId,
profileId: user.id,
email: normalizedEmail
@@ -442,7 +442,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
)
);
} catch (error) {
console.error('acceptOrganizationInvite program enrollment error:', error);
console.error('acceptOrganizationInvite cohort enrollment error:', error);
}
}
}
@@ -697,7 +697,7 @@ export async function acceptOrganizationInviteById(
const metadata = result.invite.metadata;
const courseIds = parseCourseIdsFromInviteMetadata(metadata);
const programIds = parseProgramIdsFromInviteMetadata(metadata);
const cohortIds = parseCohortIdsFromInviteMetadata(metadata);
if (courseIds.length > 0) {
try {
@@ -714,22 +714,22 @@ export async function acceptOrganizationInviteById(
}
}
if (programIds.length > 0) {
if (cohortIds.length > 0) {
try {
const existingProgramMemberships = await getExistingProgramMembers(
programIds.map((programId) => ({ programId, profileId: user.id }))
const existingCohortMemberships = await getExistingCohortMembers(
cohortIds.map((cohortId) => ({ cohortId, profileId: user.id }))
);
const programIdsToInsert = programIds.filter(
(programId) => !existingProgramMemberships.has(`${programId}:${user.id}`)
const cohortIdsToInsert = cohortIds.filter(
(cohortId) => !existingCohortMemberships.has(`${cohortId}:${user.id}`)
);
await Promise.all(
programIdsToInsert.map((programId) =>
addProgramMember({ programId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail })
cohortIdsToInsert.map((cohortId) =>
addCohortMember({ cohortId, roleId: result.invite.roleId, profileId: user.id, email: normalizedEmail })
)
);
} catch (error) {
console.error('acceptOrganizationInviteById program enrollment error:', error);
console.error('acceptOrganizationInviteById cohort enrollment error:', error);
}
}
+8 -8
View File
@@ -1,17 +1,17 @@
import {
searchLmsCourses,
searchLmsPrograms,
searchLmsCohorts,
searchOrgAudience,
searchOrgCourses,
searchOrgPrograms,
searchOrgCohorts,
searchOrgTagsAndGroups,
searchOrgWidgets
} from '@cio/db/queries';
export async function searchOrganization(orgId: string, search: string, limit: number) {
const [courses, programs, widgets, tags, audience] = await Promise.all([
const [courses, cohorts, widgets, tags, audience] = await Promise.all([
searchOrgCourses(orgId, search, limit),
searchOrgPrograms(orgId, search, limit),
searchOrgCohorts(orgId, search, limit),
searchOrgWidgets(orgId, search, limit),
searchOrgTagsAndGroups(orgId, search, limit),
searchOrgAudience(orgId, search, limit)
@@ -19,7 +19,7 @@ export async function searchOrganization(orgId: string, search: string, limit: n
return {
courses,
programs,
cohorts,
widgets,
tags,
audience
@@ -27,13 +27,13 @@ export async function searchOrganization(orgId: string, search: string, limit: n
}
export async function searchLmsOrganization(orgId: string, profileId: string, search: string, limit: number) {
const [courses, programs] = await Promise.all([
const [courses, cohorts] = await Promise.all([
searchLmsCourses(orgId, profileId, search, limit),
searchLmsPrograms(orgId, profileId, search, limit)
searchLmsCohorts(orgId, profileId, search, limit)
]);
return {
courses,
programs
cohorts
};
}
-52
View File
@@ -1,52 +0,0 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getProgramById } from '@cio/db/queries/program';
import type { TInviteStudentsToProgram, TAssignExistingStudentsToProgram } from '@cio/utils/validation/program';
import { assignAudienceToCourses, importAudienceMembers } from '@api/services/organization/audience';
/**
* Invite students to a single program by email CSV.
*
* Thin wrapper that hardcodes `programIds = [programId]` and delegates to
* `importAudienceMembers`. Lets a Program ADMIN/TUTOR (or org admin) issue org
* invites scoped to *their own* program, without granting org-wide invite rights.
*/
export async function inviteStudentsToProgram(
programId: string,
data: TInviteStudentsToProgram,
invitedByProfileId: string
) {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
return importAudienceMembers(
program.organizationId,
{
recipientCsv: data.recipientCsv,
programIds: [programId],
sendEmail: data.sendEmail,
allCourses: false,
allPrograms: false
},
invitedByProfileId
);
}
/**
* Assign existing student profiles (already members of the org audience) to a
* single program. Same scoping logic as `inviteStudentsToProgram` — restricted
* to one program at a time so the caller's program-admin permission is enough.
*/
export async function assignExistingStudentsToProgram(programId: string, data: TAssignExistingStudentsToProgram) {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
return assignAudienceToCourses(program.organizationId, {
profileIds: data.profileIds,
programIds: [programId],
sendEmail: data.sendEmail
});
}
+1 -1
View File
@@ -29,7 +29,7 @@ export async function createAudienceService(orgId: string, actorId: string | nul
recipientCsv: payload.email,
courseIds: payload.courseIds,
allCourses: false,
allPrograms: false,
allCohorts: false,
sendEmail: payload.sendEmail
},
actorId
+3 -3
View File
@@ -30,7 +30,7 @@ export function parseCourseIdsFromInviteMetadata(metadata: unknown): string[] {
return parseInviteIds(metadata, ['courseIds', 'course_ids']);
}
/** Normalize JSONB organization-invite metadata for program ids (matches importAudienceMembers storage). */
export function parseProgramIdsFromInviteMetadata(metadata: unknown): string[] {
return parseInviteIds(metadata, ['programIds', 'program_ids']);
/** Normalize JSONB organization-invite metadata for cohort ids (legacy `programIds` supported). */
export function parseCohortIdsFromInviteMetadata(metadata: unknown): string[] {
return parseInviteIds(metadata, ['cohortIds', 'cohort_ids', 'programIds', 'program_ids']);
}