feat(cohorts): rename Programs to Cohorts with data migration (#694)
* feat(cohorts): rename Programs to Cohorts with data migration - Add cohort database tables and Drizzle migration alongside legacy program tables - Introduce migrate-programs-to-cohorts script to copy existing program data safely - Rename API routes (/cohort), services, queries, validation, and error codes - Rename dashboard feature module, routes (/cohorts), navigation, and translations - Update docs, emails, and internal compliance cron endpoints for cohort terminology - Keep legacy program tables in place for rollback until explicitly removed Co-authored-by: Rotimi Best <[email protected]> * fix(db): restore migrate:answer-data script alongside cohort migration Co-authored-by: Rotimi Best <[email protected]> * fix(db): cast program enums when migrating data to cohort tables Co-authored-by: Rotimi Best <[email protected]> * fix(db): truncate cohort tables before program-to-cohort copy Co-authored-by: Rotimi Best <[email protected]> * fix(db): report accurate row counts in programs-to-cohorts migration Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
fe9f8c441f
commit
ec61bfe7d9
+2
-2
@@ -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)
|
||||
|
||||
+14
-14
@@ -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
|
||||
);
|
||||
+18
-18
@@ -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,
|
||||
+10
-10
@@ -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
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { cohortRouter } from './cohort';
|
||||
@@ -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 +0,0 @@
|
||||
export { programRouter } from './program';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
+153
-157
@@ -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,
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user