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 <rotimi-best@users.noreply.github.com>

* fix(db): restore migrate:answer-data script alongside cohort migration

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(db): cast program enums when migrating data to cohort tables

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(db): truncate cohort tables before program-to-cohort copy

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(db): report accurate row counts in programs-to-cohorts migration

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

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