Files
classroomio/apps/api/src/services/program/program.ts
T

583 lines
19 KiB
TypeScript

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';
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';
import { getCourseGroupIds } from '@cio/db/queries/course';
import { insertGroupMembersOnConflictDoNothing } from '@cio/db/queries/group';
import { getProfileByEmail } from '@cio/db/queries/auth';
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
type ProgramMemberEnrollment = {
profileId: string;
email: string | null;
roleId: number;
};
async function enrollProgramStudentsInGroups(
organizationId: string,
groupIds: string[],
members: ProgramMemberEnrollment[]
) {
const studentMembers = members.filter((member) => member.profileId && member.roleId === ROLE.STUDENT);
const uniqueGroupIds = [...new Set(groupIds)];
if (studentMembers.length === 0 || uniqueGroupIds.length === 0) {
return 0;
}
const organizationMemberRows = studentMembers.map((member) => ({
organizationId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: member.email?.trim() || undefined,
verified: true
}));
const groupMemberRows = uniqueGroupIds.flatMap((groupId) =>
studentMembers.map((member) => ({
groupId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: member.email?.trim() || undefined
}))
);
await db.transaction(async (tx) => {
await insertOrganizationMembersOnConflictDoNothing(organizationMemberRows, tx);
await insertGroupMembersOnConflictDoNothing(groupMemberRows, tx);
});
return groupMemberRows.length;
}
// ─── Program CRUD ────────────────────────────────────────────────────────────
export async function createProgram(organizationId: string, profileId: string, data: TCreateProgram) {
try {
const description = data.description?.trim() || `A program for ${data.name}`;
return createProgramWithCreatorMembership(
{
organizationId,
createdByProfileId: profileId,
name: data.name,
description,
coverImage: data.coverImage
},
profileId
);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to create program',
ErrorCodes.PROGRAM_CREATE_FAILED,
500
);
}
}
export async function getProgram(programId: string) {
try {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
return program;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to get program',
ErrorCodes.PROGRAM_NOT_FOUND,
500
);
}
}
export async function listOrgPrograms(organizationId: string) {
try {
return getProgramsByOrg(organizationId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list programs',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function updateProgram(programId: string, data: TUpdateProgram) {
try {
const updated = await updateProgramQuery(programId, data);
if (!updated) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_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,
500
);
}
}
export async function deleteProgram(programId: string) {
try {
const deleted = await deleteProgramQuery(programId);
if (!deleted) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_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,
500
);
}
}
// ─── Program Members ─────────────────────────────────────────────────────────
export async function listProgramMembers(programId: string) {
try {
return getProgramMembers(programId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list program members',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function addProgramMembers(programId: string, data: TAddProgramMembers) {
try {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
const programCourses = await getCoursesByProgram(programId);
const courseGroupIds = (await getCourseGroupIds(programCourses.map((course) => course.course.id))).map(
(courseGroup) => courseGroup.groupId
);
const results = await Promise.allSettled(
data.members.map(async ({ profileId: providedProfileId, email, roleId }) => {
const profile = !providedProfileId && email ? await getProfileByEmail(email) : null;
const profileId = providedProfileId ?? profile?.id ?? null;
const normalizedEmail = email?.toLowerCase().trim() ?? profile?.email ?? null;
if (profileId) {
const existing = await getProgramMemberByProfileId(programId, profileId);
if (existing) {
throw new AppError(
`${email} is already a member of this program`,
ErrorCodes.MEMBER_ALREADY_IN_PROGRAM,
409
);
}
}
if (!profileId && !email) {
throw new AppError(
'Either profileId or email must be provided for each member',
ErrorCodes.VALIDATION_ERROR,
400
);
}
return db.transaction(async (tx) => {
const member = await addProgramMember(
{
programId,
profileId,
roleId,
email: normalizedEmail ?? undefined
},
tx
);
if (member && member.roleId === ROLE.STUDENT && member.profileId && courseGroupIds.length > 0) {
const validCourseGroupIds = courseGroupIds.filter((groupId): groupId is string => Boolean(groupId));
await insertOrganizationMembersOnConflictDoNothing(
[
{
organizationId: program.organizationId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: normalizedEmail ?? undefined,
verified: true
}
],
tx
);
await insertGroupMembersOnConflictDoNothing(
validCourseGroupIds.map((groupId) => ({
groupId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: normalizedEmail ?? undefined
})),
tx
);
}
return member;
});
})
);
const added = results
.filter((r) => r.status === 'fulfilled')
.map((r) => (r as PromiseFulfilledResult<unknown>).value);
const errors = results
.filter((r) => r.status === 'rejected')
.map((r) => (r as PromiseRejectedResult).reason?.message || 'Unknown error');
return { added, errors };
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to add program members',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function removeProgramMemberService(_programId: string, memberId: string) {
try {
const deleted = await removeProgramMember(memberId);
if (!deleted) {
throw new AppError('Program member not found', ErrorCodes.PROGRAM_MEMBER_NOT_FOUND, 404);
}
return deleted;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to remove program member',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function updateProgramMemberService(_programId: string, memberId: string, data: TUpdateProgramMember) {
try {
const updated = await updateProgramMemberQuery(memberId, { roleId: data.roleId });
if (!updated) {
throw new AppError('Program member not found', ErrorCodes.PROGRAM_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',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function getEnrolledPrograms(profileId: string) {
try {
return getEnrolledProgramsByProfile(profileId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to get enrolled programs',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
// ─── Program Courses ─────────────────────────────────────────────────────────
export async function listProgramCourses(programId: string, profileId: string) {
try {
const roleId = await getProgramMemberRole(programId, profileId);
const onlyPublished = roleId === ROLE.STUDENT;
return getCoursesByProgram(programId, onlyPublished);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list program courses',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function addCourseToProgramService(programId: string, data: TAddCourseToProgram) {
try {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
const alreadyAdded = await isProgramCourse(programId, data.courseId);
if (alreadyAdded) {
throw new AppError('Course is already in this program', ErrorCodes.COURSE_ALREADY_IN_PROGRAM, 409);
}
const result = await addCourseToProgram(programId, data.courseId);
const students = (await getProgramMembers(programId))
.filter((member) => member.roleId === ROLE.STUDENT && member.profileId)
.map((member) => ({
profileId: member.profileId!,
email: member.email ?? null,
roleId: member.roleId
}));
const courseGroupIds = (await getCourseGroupIds([data.courseId]))
.map((courseGroup) => courseGroup.groupId)
.filter((groupId): groupId is string => Boolean(groupId));
if (students.length > 0 && courseGroupIds.length > 0) {
await enrollProgramStudentsInGroups(program.organizationId, courseGroupIds, students);
}
return result;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to add course to program',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function removeCourseFromProgramService(programId: string, courseId: string) {
try {
const deleted = await removeCourseFromProgram(programId, courseId);
if (!deleted) {
throw new AppError('Course not found in program', ErrorCodes.NOT_FOUND, 404);
}
return deleted;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to remove course from program',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
// ─── Program Newsfeed ────────────────────────────────────────────────────────
export async function listProgramNewsfeed(programId: string, options: { cursor?: string; limit: number }) {
try {
return getProgramNewsfeed(programId, options);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list program newsfeed',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function createProgramNewsfeedService(programId: string, profileId: string, data: TCreateProgramNewsfeed) {
try {
const member = await getProgramMemberByProfileId(programId, profileId);
if (!member) {
throw new AppError('User is not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
}
return createProgramNewsfeedQuery({
programId,
authorId: member.id,
content: data.content,
isPinned: data.isPinned ?? false
});
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to create program newsfeed',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function updateProgramNewsfeedService(feedId: string, data: TUpdateProgramNewsfeed) {
try {
const feed = await getProgramNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
}
return updateProgramNewsfeedQuery(feedId, data);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update program newsfeed',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function updateProgramNewsfeedReactionService(feedId: string, data: TUpdateProgramReaction) {
try {
const feed = await getProgramNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
}
return updateProgramNewsfeedReaction(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',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function deleteProgramNewsfeedService(feedId: string) {
try {
const feed = await getProgramNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
}
return deleteProgramNewsfeedQuery(feedId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete program newsfeed',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
// ─── Program Newsfeed Comments ────────────────────────────────────────────────
export async function listProgramNewsfeedComments(feedId: string) {
try {
const feed = await getProgramNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
}
return getProgramNewsfeedComments(feedId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to list program newsfeed comments',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function createProgramNewsfeedCommentService(
feedId: string,
profileId: string,
data: TCreateProgramNewsfeedComment
) {
try {
const feed = await getProgramNewsfeedById(feedId);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
}
const member = await getProgramMemberByProfileId(feed.programId!, profileId);
if (!member) {
throw new AppError('User is not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
}
return createProgramNewsfeedCommentQuery({
programNewsfeedId: 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',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function deleteProgramNewsfeedCommentService(commentId: number) {
try {
const comment = await getProgramNewsfeedCommentById(commentId);
if (!comment) {
throw new AppError('Comment not found', ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND, 404);
}
const feed = await getProgramNewsfeedById(comment.programNewsfeedId!);
if (!feed) {
throw new AppError('Program newsfeed item not found', ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND, 404);
}
return deleteProgramNewsfeedCommentQuery(commentId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete program newsfeed comment',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
// ─── Auth Helpers ────────────────────────────────────────────────────────────
async function assertProgramMember(programId: string, profileId: string) {
const member = await isProgramMember(programId, profileId);
if (!member) {
throw new AppError('You are not a member of this program', ErrorCodes.PROGRAM_FORBIDDEN, 403);
}
}